diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 46358486d..b8c0c80a3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -56,10 +56,10 @@ Chayn team members usually respond within 3 business days. Chayn is open to all kinds of contributions, such as: - additional software tests / test coverage -- dependency updates *check Dependabot pull requests +- dependency updates \*check Dependabot pull requests - code (requested features, bug fixes, quality enhancements, maintenance help) - accessibility and language support. -- no-code (documentation, translations) *see spam policy below for accepted documentation changes. +- no-code (documentation, translations) \*see spam policy below for accepted documentation changes. # Chayn's Spam Contribution Policy 🚫 diff --git a/app/ThemeRegistry.tsx b/app/ThemeRegistry.tsx new file mode 100644 index 000000000..873d699e3 --- /dev/null +++ b/app/ThemeRegistry.tsx @@ -0,0 +1,17 @@ +import { AppRouterCacheProvider } from '@mui/material-nextjs/v13-appRouter'; +import CssBaseline from '@mui/material/CssBaseline'; +import { ThemeProvider } from '@mui/material/styles'; +import theme from '../styles/theme'; + +// This implementation is from mui integrations with nextjs app router +// see https://mui.com/material-ui/integrations/nextjs/#app-router +export default function ThemeRegistry({ children }: { children: React.ReactNode }) { + return ( + <AppRouterCacheProvider> + <ThemeProvider theme={theme}> + <CssBaseline /> + {children} + </ThemeProvider> + </AppRouterCacheProvider> + ); +} diff --git a/pages/chat.tsx b/app/[locale]/chat/chat.tsx similarity index 58% rename from pages/chat.tsx rename to app/[locale]/chat/chat.tsx index 895d77718..024236178 100644 --- a/pages/chat.tsx +++ b/app/[locale]/chat/chat.tsx @@ -1,22 +1,18 @@ +'use client'; + import { Box } from '@mui/material'; import { ISbStoryData, useStoryblokState } from '@storyblok/react'; -import { GetStaticPropsContext, NextPage } from 'next'; import { useTranslations } from 'next-intl'; import Head from 'next/head'; -import { SignUpBanner } from '../components/banner/SignUpBanner'; -import NoDataAvailable from '../components/common/NoDataAvailable'; -import CrispButton from '../components/crisp/CrispButton'; -import Header, { HeaderProps } from '../components/layout/Header'; -import StoryblokPageSection from '../components/storyblok/StoryblokPageSection'; -import { useTypedSelector } from '../hooks/store'; -import { getStoryblokPageProps } from '../utils/getStoryblokPageProps'; -import { getEventUserData } from '../utils/logEvent'; - -interface Props { - story: ISbStoryData | null; -} +import { SignUpBanner } from '../../../components/banner/SignUpBanner'; +import NoDataAvailable from '../../../components/common/NoDataAvailable'; +import CrispButton from '../../../components/crisp/CrispButton'; +import Header, { HeaderProps } from '../../../components/layout/Header'; +import StoryblokPageSection from '../../../components/storyblok/StoryblokPageSection'; +import { useTypedSelector } from '../../../hooks/store'; +import { getEventUserData } from '../../../utils/logEvent'; -const Chat: NextPage<Props> = ({ story }) => { +const Chat = ({ story }: { story: ISbStoryData | null }) => { story = useStoryblokState(story); const t = useTranslations('Courses'); @@ -68,21 +64,4 @@ const Chat: NextPage<Props> = ({ story }) => { ); }; -export async function getStaticProps({ locale, preview = false }: GetStaticPropsContext) { - const storyblokProps = await getStoryblokPageProps('chat', locale, preview); - - return { - props: { - ...storyblokProps, - messages: { - ...require(`../messages/shared/${locale}.json`), - ...require(`../messages/navigation/${locale}.json`), - ...require(`../messages/courses/${locale}.json`), - ...require(`../messages/chat/${locale}.json`), - }, - }, - revalidate: 3600, // revalidate every hour - }; -} - export default Chat; diff --git a/app/[locale]/chat/page.tsx b/app/[locale]/chat/page.tsx new file mode 100644 index 000000000..a3e130a20 --- /dev/null +++ b/app/[locale]/chat/page.tsx @@ -0,0 +1,19 @@ +import { getLocale } from 'next-intl/server'; +import { locales } from '../../../i18n/config'; +import { getStoryblokPageProps } from '../../../utils/getStoryblokPageProps'; +import Chat from './chat'; + +export const revalidate = 3600; + +export default async function Page() { + const preview = false; + const locale = await getLocale(); + const storyblokProps = await getStoryblokPageProps('chat', locale, preview); + return <Chat story={storyblokProps?.story}></Chat>; +} + +export async function generateStaticParams() { + return locales.map((locale) => { + return { params: { locale } }; + }); +} diff --git a/app/[locale]/layout.tsx b/app/[locale]/layout.tsx new file mode 100644 index 000000000..450d822f8 --- /dev/null +++ b/app/[locale]/layout.tsx @@ -0,0 +1,13 @@ +import { locales } from '../../i18n/config'; + +const Layout = ({ children }: { children: React.ReactNode }) => { + return <>{children}</>; +}; + +export async function generateStaticParams() { + return locales.map((locale) => { + return { params: { locale } }; + }); +} + +export default Layout; diff --git a/app/[locale]/meet-the-team/meet-the-team.tsx b/app/[locale]/meet-the-team/meet-the-team.tsx new file mode 100644 index 000000000..e6304ea13 --- /dev/null +++ b/app/[locale]/meet-the-team/meet-the-team.tsx @@ -0,0 +1,23 @@ +'use client'; + +import { ISbStoryData, useStoryblokState } from '@storyblok/react'; +import NoDataAvailable from '../../../components/common/NoDataAvailable'; +import StoryblokMeetTheTeamPage, { + StoryblokMeetTheTeamPageProps, +} from '../../../components/storyblok/StoryblokMeetTheTeamPage'; + +interface MeetTheTeamProps { + story: ISbStoryData | null; +} + +const MeetTheTeam = ({ story }: MeetTheTeamProps) => { + const storyData = useStoryblokState(story); + + if (!storyData) { + return <NoDataAvailable />; + } + + return <StoryblokMeetTheTeamPage {...(storyData.content as StoryblokMeetTheTeamPageProps)} />; +}; + +export default MeetTheTeam; diff --git a/app/[locale]/meet-the-team/page.tsx b/app/[locale]/meet-the-team/page.tsx new file mode 100644 index 000000000..a24daf43a --- /dev/null +++ b/app/[locale]/meet-the-team/page.tsx @@ -0,0 +1,11 @@ +import { getStoryblokPageProps } from '../../../utils/getStoryblokPageProps'; +import MeetTheTeam from './meet-the-team'; + +export const revalidate = 3600; + +export default async function Page({ params }: { params: { locale: string } }) { + const preview = false; + const locale = params.locale; + const storyblokProps = await getStoryblokPageProps('meet-the-team', locale, preview); + return <MeetTheTeam story={storyblokProps?.story}></MeetTheTeam>; +} diff --git a/app/[locale]/welcome/[partnerName]/page.tsx b/app/[locale]/welcome/[partnerName]/page.tsx new file mode 100644 index 000000000..903489a8b --- /dev/null +++ b/app/[locale]/welcome/[partnerName]/page.tsx @@ -0,0 +1,46 @@ +import { getStoryblokApi, ISbStoriesParams, ISbStoryData } from '@storyblok/react'; +import { locales } from '../../../../i18n/config'; +import { getStoryblokPageProps } from '../../../../utils/getStoryblokPageProps'; +import Welcome from './welcome'; + +export const revalidate = 3600; + +export default async function Page({ + params, +}: { + params: { partnerName: string; locale: string }; +}) { + const preview = false; + const locale = params.locale; + const partnerName = params?.partnerName; + const storyblokProps = await getStoryblokPageProps(`welcome/${partnerName}`, locale, preview); + return <Welcome story={storyblokProps?.story}></Welcome>; +} + +export async function generateStaticParams() { + let sbParams: ISbStoriesParams = { + published: true, + starts_with: 'partnership/', + }; + + const storyblokApi = getStoryblokApi(); + let data = await storyblokApi.getAll('cdn/links', sbParams); + + let paths: any = []; + + data.forEach((story: Partial<ISbStoryData>) => { + if (!story.slug) return; + + // get array for slug because of catch all + let splittedSlug = story.slug.split('/'); + + if (locales) { + // create additional languages + for (const locale of locales) { + paths.push({ params: { partnerName: splittedSlug[1] } }); + } + } + }); + + return paths; +} diff --git a/app/[locale]/welcome/[partnerName]/welcome.tsx b/app/[locale]/welcome/[partnerName]/welcome.tsx new file mode 100644 index 000000000..9b5edd091 --- /dev/null +++ b/app/[locale]/welcome/[partnerName]/welcome.tsx @@ -0,0 +1,28 @@ +'use client'; + +import { ISbStoryData, useStoryblokState } from '@storyblok/react'; +import NoDataAvailable from '../../../../components/common/NoDataAvailable'; +import StoryblokWelcomePage, { + StoryblokWelcomePageProps, +} from '../../../../components/storyblok/StoryblokWelcomePage'; + +interface WelcomeProps { + story: ISbStoryData | null; +} + +const Welcome = ({ story }: WelcomeProps) => { + story = useStoryblokState(story); + + if (!story) { + return <NoDataAvailable />; + } + + return ( + <StoryblokWelcomePage + {...(story.content as StoryblokWelcomePageProps)} + storySlug={story.slug} + /> + ); +}; + +export default Welcome; diff --git a/app/appLayout.tsx b/app/appLayout.tsx new file mode 100644 index 000000000..ad804588f --- /dev/null +++ b/app/appLayout.tsx @@ -0,0 +1,61 @@ +'use client'; + +import { Analytics } from '@mui/icons-material'; +import { usePathname } from 'next/navigation'; +import { Hotjar } from 'nextjs-hotjar'; +import { useEffect } from 'react'; +import { AppBarSpacer } from '../components/layout/AppBarSpacer'; +import Consent from '../components/layout/Consent'; +import Footer from '../components/layout/Footer'; +import LanguageMenuAppRoute from '../components/layout/LanguageMenuAppRoute'; +import LeaveSiteButton from '../components/layout/LeaveSiteButton'; +import TopBar from '../components/layout/TopBar'; +import firebase from '../config/firebase'; +import { AuthGuard } from '../guards/AuthGuard'; + +interface AppLayoutProps { + children?: React.ReactNode; +} + +// Init firebase +firebase; + +export default function AppLayout({ children }: AppLayoutProps) { + const pathname = usePathname(); + + // Get top level directory of path e.g pathname /courses/course_name has pathHead courses + const pathHead = pathname?.split('/')[1]; // E.g. courses | therapy | partner-admin + + useEffect(() => { + // Check if entry path is from a partner referral and if so, store referring partner in local storage + // This enables us to redirect a user to the correct sign up page later (e.g. in SignUpBanner) + const path = pathname; + + if (path?.includes('/welcome/')) { + const referralPartner = path.split('/')[2]; // Gets "bumble" from /welcome/bumble + + if (referralPartner) { + window.localStorage.setItem('referralPartner', referralPartner); + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return ( + <> + <TopBar> + <LanguageMenuAppRoute /> + </TopBar> + <AppBarSpacer /> + {pathHead !== 'partner-admin' && <LeaveSiteButton />} + <AuthGuard>{children as JSX.Element}</AuthGuard> + <Footer /> + <Consent /> + {!!process.env.NEXT_PUBLIC_HOTJAR_ID && process.env.NEXT_PUBLIC_ENV !== 'local' && ( + <Hotjar id={process.env.NEXT_PUBLIC_HOTJAR_ID} sv={6} strategy="lazyOnload" /> + )} + {/* Vercel analytics */} + <Analytics /> + </> + ); +} diff --git a/app/layout.tsx b/app/layout.tsx new file mode 100644 index 000000000..fe5744a1c --- /dev/null +++ b/app/layout.tsx @@ -0,0 +1,91 @@ +import newrelic from 'newrelic'; +import Script from 'next/script'; + +import { Metadata } from 'next'; +import { NextIntlClientProvider } from 'next-intl'; +import { getLocale, getMessages } from 'next-intl/server'; +import CrispScript from '../components/crisp/CrispScript'; +import GoogleTagManagerScript from '../components/head/GoogleTagManagerScript'; +import OpenGraphMetadata from '../components/head/OpenGraphMetadata'; +import RollbarScript from '../components/head/RollbarScript'; +import ErrorBoundary from '../components/layout/ErrorBoundary'; +import { storyblok } from '../config/storyblok'; +import StoreProvider from '../store/storeProvider'; +import '../styles/globals.css'; +import AppLayout from './appLayout'; +import ThemeRegistry from './ThemeRegistry'; + +// Init storyblok +storyblok; + +export const metadata: Metadata = { + title: 'Bloom', +}; + +export const dynamicParams = false; + +export default async function RootLayout({ + // Layouts must accept a children prop. + // This will be populated with nested layouts or pages + children, +}: { + children: React.ReactNode; +}) { + // Configuration according to Newrelic app router example + // See https://github.com/newrelic/newrelic-node-nextjs?tab=readme-ov-file#example-projects + // @ts-ignore + if (newrelic.agent.collector.isConnected() === false) { + await new Promise((resolve) => { + // @ts-ignore + newrelic.agent.on('connected', resolve); + }); + } + + const browserTimingHeader = newrelic.getBrowserTimingHeader({ + hasToRemoveScriptWrapper: true, + // @ts-ignore + allowTransactionlessInjection: true, + }); + + const locale = await getLocale(); + + // Providing all messages to the client + // side is the easiest way to get started + const messages = await getMessages(); + + return ( + <html lang={locale}> + <head> + <OpenGraphMetadata /> + <GoogleTagManagerScript /> + <RollbarScript /> + </head> + <body> + <StoreProvider> + <ErrorBoundary> + <NextIntlClientProvider messages={messages}> + <CrispScript /> + <ThemeRegistry> + <AppLayout>{children}</AppLayout> + </ThemeRegistry> + </NextIntlClientProvider> + </ErrorBoundary> + </StoreProvider> + <Script + // We have to set an id for inline scripts. + // See https://nextjs.org/docs/app/building-your-application/optimizing/scripts#inline-scripts + id="nr-browser-agent" + // By setting the strategy to "beforeInteractive" we guarantee that + // the script will be added to the document's `head` element. + strategy="beforeInteractive" + // The body of the script element comes from the async evaluation + // of `getInitialProps`. We use the special + // `dangerouslySetInnerHTML` to provide that element body. Since + // it requires an object with an `__html` property, we pass in an + // object literal. + dangerouslySetInnerHTML={{ __html: browserTimingHeader }} + /> + </body> + </html> + ); +} diff --git a/app/not-found.tsx b/app/not-found.tsx new file mode 100644 index 000000000..180594f57 --- /dev/null +++ b/app/not-found.tsx @@ -0,0 +1,5 @@ +import { redirect } from 'next/navigation'; + +export default function Page() { + redirect('/404'); +} diff --git a/components/banner/UserResearchBanner.tsx b/components/banner/UserResearchBanner.tsx index ac715ebeb..0bd4da29a 100644 --- a/components/banner/UserResearchBanner.tsx +++ b/components/banner/UserResearchBanner.tsx @@ -1,6 +1,6 @@ import { Alert, AlertTitle, Button, Collapse, Stack } from '@mui/material'; import Cookies from 'js-cookie'; -import { useRouter } from 'next/router'; +import { usePathname } from 'next/navigation'; import React from 'react'; import { FeatureFlag } from '../../config/featureFlag'; import { USER_BANNER_DISMISSED, USER_BANNER_INTERESTED } from '../../constants/events'; @@ -28,7 +28,7 @@ export default function UserResearchBanner() { const partnerAdmin = useTypedSelector((state) => state.partnerAdmin); const eventUserData = getEventUserData(userCreatedAt, partnerAccesses, partnerAdmin); - const router = useRouter(); + const pathname = usePathname(); const isBannerNotInteracted = !Boolean(Cookies.get(USER_RESEARCH_BANNER_INTERACTED)); const isBannerFeatureEnabled = FeatureFlag.isUserResearchBannerEnabled(); // const isPublicUser = partnerAccesses.length === 0 && !partnerAdmin.id; @@ -36,9 +36,7 @@ export default function UserResearchBanner() { return pa.partner.name.toLowerCase() === 'badoo'; }); - const isTargetPage = !( - router.pathname.includes('auth') || router.pathname.includes('partnerName') - ); + const isTargetPage = !(pathname?.includes('auth') || pathname?.includes('partnerName')); const showBanner = isBannerFeatureEnabled && isBadooUser && isTargetPage && isBannerNotInteracted; return showBanner ? ( diff --git a/components/common/Link.tsx b/components/common/Link.tsx index 13d1fdc56..c1a99ded5 100644 --- a/components/common/Link.tsx +++ b/components/common/Link.tsx @@ -1,9 +1,11 @@ +'use client'; + import OpenInNew from '@mui/icons-material/OpenInNew'; import MuiLink, { LinkProps as MuiLinkProps } from '@mui/material/Link'; import { styled } from '@mui/material/styles'; import clsx from 'clsx'; import NextLink, { LinkProps as NextLinkProps } from 'next/link'; -import { useRouter } from 'next/router'; +import { usePathname } from 'next/navigation'; import * as React from 'react'; // Add support for the sx prop for consistency with the other branches. @@ -65,10 +67,10 @@ const Link = React.forwardRef<HTMLAnchorElement, LinkProps>(function Link(props, ...other } = props; - const router = useRouter(); - const pathname = typeof href === 'string' ? href : href.pathname; + const pathname = usePathname(); + const hrefPathname = typeof href === 'string' ? href : href.pathname; const className = clsx(classNameProps, { - [activeClassName]: router.pathname === pathname && activeClassName, + [activeClassName]: pathname === hrefPathname && activeClassName, }); const isExternal = diff --git a/components/crisp/CrispScript.tsx b/components/crisp/CrispScript.tsx index 82ae6875a..4b8c77eb4 100644 --- a/components/crisp/CrispScript.tsx +++ b/components/crisp/CrispScript.tsx @@ -1,4 +1,6 @@ -import { useRouter } from 'next/router'; +'use client'; + +import { useLocale } from 'next-intl'; import Script from 'next/script'; import { useEffect } from 'react'; import { CHAT_MESSAGE_SENT, CHAT_STARTED, FIRST_CHAT_STARTED } from '../../constants/events'; @@ -7,6 +9,7 @@ import logEvent, { getEventUserData } from '../../utils/logEvent'; import { createCrispProfileData } from './utils/createCrispProfileData'; const CrispScript = () => { + const locale = useLocale(); const userCreatedAt = useTypedSelector((state) => state.user.createdAt); const userEmail = useTypedSelector((state) => state.user.email); const userCrispTokenId = useTypedSelector((state) => state.user.crispTokenId); @@ -14,8 +17,6 @@ const CrispScript = () => { const partnerAdmin = useTypedSelector((state) => state.partnerAdmin); const courses = useTypedSelector((state) => state.courses); - const router = useRouter(); - const eventUserData = getEventUserData(userCreatedAt, partnerAccesses, partnerAdmin); useEffect(() => { @@ -81,7 +82,7 @@ const CrispScript = () => { __html: ` window.$crisp=[]; CRISP_RUNTIME_CONFIG = { - locale : ${router.locale ? `"${router.locale}"` : 'en'} + locale : ${locale ? `"${locale}"` : 'en'} }; window.CRISP_WEBSITE_ID="${process.env.NEXT_PUBLIC_CRISP_WEBSITE_ID}"; (function(){ diff --git a/components/forms/WelcomeCodeForm.tsx b/components/forms/WelcomeCodeForm.tsx index 2e3ca6420..2b171d1be 100644 --- a/components/forms/WelcomeCodeForm.tsx +++ b/components/forms/WelcomeCodeForm.tsx @@ -1,6 +1,6 @@ import { Box, Button, TextField } from '@mui/material'; import { useTranslations } from 'next-intl'; -import { useRouter } from 'next/router'; +import { useRouter } from 'next/navigation'; import * as React from 'react'; import { useEffect, useState } from 'react'; import { generatePartnerPromoGetStartedEvent } from '../../constants/events'; diff --git a/components/head/OpenGraphMetadata.tsx b/components/head/OpenGraphMetadata.tsx index 771872a24..79c4f8519 100644 --- a/components/head/OpenGraphMetadata.tsx +++ b/components/head/OpenGraphMetadata.tsx @@ -1,4 +1,4 @@ -import theme from '../../styles/theme'; +import { COLOUR_PRIMARY_MAIN } from '../../styles/common'; const descriptionContent = 'Join us on your healing journey. Bloom is here for you to learn, heal and grow towards a confident future. It is bought to you by Chayn, a global non-profit, run by survivors and allies from around the world.'; @@ -19,7 +19,7 @@ const OpenGraphMetadata = () => { {/** PWA specific tags **/} <link rel="manifest" href="/manifest.json" /> <link rel="apple-touch-icon" href="/icons/apple/icon-120x120.png"></link> - <meta name="theme-color" content={theme.palette.primary.main} /> + <meta name="theme-color" content={COLOUR_PRIMARY_MAIN} /> </> ); }; diff --git a/components/head/RollbarScript.tsx b/components/head/RollbarScript.tsx index 57b74f2eb..6e0a71d8b 100644 --- a/components/head/RollbarScript.tsx +++ b/components/head/RollbarScript.tsx @@ -1,19 +1,22 @@ +import Script from 'next/script'; + const RollbarScript = () => { return ( - <script + <Script + id="rollbar" dangerouslySetInnerHTML={{ __html: ` var _rollbarConfig = { - accessToken: "${process.env.NEXT_PUBLIC_ROLLBAR_CLIENT_TOKEN}", + accessToken: '${process.env.NEXT_PUBLIC_ROLLBAR_CLIENT_TOKEN}', captureUncaught: true, captureUnhandledRejections: true, captureIP: "anonymize", payload: { - environment: "${process.env.NEXT_PUBLIC_ENV}", + environment: '${process.env.NEXT_PUBLIC_ENV}', client: { javascript: { source_map_enabled: true, - code_version: "${process.env.NEXT_PUBLIC_VERCEL_GIT_COMMIT_SHA}", + code_version: '${process.env.NEXT_PUBLIC_VERCEL_GIT_COMMIT_SHA}', guess_uncaught_frames: true, }, }, diff --git a/components/layout/ErrorBoundary.tsx b/components/layout/ErrorBoundary.tsx index c744219ea..adbcdffbf 100644 --- a/components/layout/ErrorBoundary.tsx +++ b/components/layout/ErrorBoundary.tsx @@ -1,3 +1,5 @@ +'use client'; + import { Alert, Snackbar } from '@mui/material'; import { Component, ErrorInfo, ReactNode } from 'react'; diff --git a/components/layout/Footer.tsx b/components/layout/Footer.tsx index b85c967c3..b7c53690f 100644 --- a/components/layout/Footer.tsx +++ b/components/layout/Footer.tsx @@ -6,7 +6,7 @@ import YoutubeIcon from '@mui/icons-material/YouTube'; import { Box, Container, IconButton, Typography } from '@mui/material'; import { useTranslations } from 'next-intl'; import Image from 'next/image'; -import { useRouter } from 'next/router'; +import { usePathname, useSearchParams } from 'next/navigation'; import { useEffect, useState } from 'react'; import { PARTNER_SOCIAL_LINK_CLICKED, SOCIAL_LINK_CLICKED } from '../../constants/events'; import { PartnerContent, getPartnerContent } from '../../constants/partners'; @@ -91,7 +91,8 @@ const Footer = () => { const tS = useTranslations('Shared'); const [eventUserData, setEventUserData] = useState<any>(null); const [partners, setPartners] = useState<PartnerContent[]>([]); - const router = useRouter(); + const pathname = usePathname() as string; + const searchParams = useSearchParams(); const userCreatedAt = useTypedSelector((state) => state.user.createdAt); const partnerAccesses = useTypedSelector((state) => state.partnerAccesses); @@ -116,14 +117,14 @@ const Footer = () => { addUniquePartner(partnersList, partnerAccess.partner.name); }); - const { partner } = router.query; + const partner = searchParams?.get('partner'); if (partner) { addUniquePartner(partnersList, partner + ''); } - if (router.pathname.includes('/welcome') || router.pathname.includes('/partnership')) { - const partnerName = router.asPath.split('/')[2].split('?')[0]; + if (pathname.includes('/welcome') || pathname.includes('/partnership')) { + const partnerName = pathname.split('/')[2].split('?')[0]; addUniquePartner(partnersList, partnerName); } @@ -134,7 +135,7 @@ const Footer = () => { } setPartners(partnersList); - }, [partnerAccesses, userCreatedAt, router, partnerAdmin]); + }, [partnerAccesses, userCreatedAt, searchParams, partnerAdmin, pathname]); return ( <> diff --git a/components/layout/Header.tsx b/components/layout/Header.tsx index 873ad2792..d7294853e 100644 --- a/components/layout/Header.tsx +++ b/components/layout/Header.tsx @@ -3,7 +3,7 @@ import { Box, Container, IconButton, Typography } from '@mui/material'; import { ISbRichtext } from '@storyblok/react'; import { useTranslations } from 'next-intl'; import Image, { StaticImageData } from 'next/image'; -import { useRouter } from 'next/router'; +import { useRouter } from 'next/navigation'; import { JSXElementConstructor, ReactElement, ReactNodeArray } from 'react'; import { render } from 'storyblok-rich-text-react-renderer'; import { PROGRESS_STATUS } from '../../constants/enums'; diff --git a/components/layout/LanguageMenu.tsx b/components/layout/LanguageMenu.tsx index 048eb019c..e56a70694 100644 --- a/components/layout/LanguageMenu.tsx +++ b/components/layout/LanguageMenu.tsx @@ -1,12 +1,15 @@ +'use client'; + import LanguageIcon from '@mui/icons-material/Language'; import { Box, Button, Menu, MenuItem } from '@mui/material'; import { useTranslations } from 'next-intl'; -import { useRouter } from 'next/router'; import * as React from 'react'; -import { HEADER_LANGUAGE_MENU_CLICKED, generateLanguageMenuEvent } from '../../constants/events'; +import { useCookies } from 'react-cookie'; +import { generateLanguageMenuEvent, HEADER_LANGUAGE_MENU_CLICKED } from '../../constants/events'; import { useTypedSelector } from '../../hooks/store'; +import { COOKIE_LOCALE_NAME, COOKIE_LOCALE_PATH, locales } from '../../i18n/config'; +import { Link, usePathname } from '../../i18n/navigation'; import logEvent, { getEventUserData } from '../../utils/logEvent'; -import Link from '../common/Link'; const menuItemStyle = { ':hover': { backgroundColor: 'transparent' }, @@ -40,10 +43,9 @@ const buttonStyle = { '& .MuiButton-startIcon': { display: 'inline-flex', mx: 0 }, } as const; -export default function LanguageMenu() { - const router = useRouter(); - const locale = router.locale; - const locales = router.locales; +export default function LanguageMenu({ locale }: { locale: string }) { + const [cookies, setCookie] = useCookies([COOKIE_LOCALE_NAME]); + const pathname = usePathname(); const t = useTranslations('Navigation'); const userCreatedAt = useTypedSelector((state) => state.user.createdAt); const partnerAccesses = useTypedSelector((state) => state.partnerAccesses); @@ -94,9 +96,10 @@ export default function LanguageMenu() { <MenuItem key={language} sx={menuItemStyle}> <Button component={Link} - href={router.asPath} + href={pathname as string} locale={language} onClick={() => { + setCookie(COOKIE_LOCALE_NAME, language, { path: COOKIE_LOCALE_PATH }); logEvent(generateLanguageMenuEvent(language), eventUserData); handleClose(); }} diff --git a/components/layout/LanguageMenuAppRoute.tsx b/components/layout/LanguageMenuAppRoute.tsx new file mode 100644 index 000000000..bf98a42a0 --- /dev/null +++ b/components/layout/LanguageMenuAppRoute.tsx @@ -0,0 +1,121 @@ +import LanguageIcon from '@mui/icons-material/Language'; +import { Box, Button, Menu, MenuItem } from '@mui/material'; +import { useLocale, useTranslations } from 'next-intl'; +import { usePathname } from 'next/navigation'; +import * as React from 'react'; +import { generateLanguageMenuEvent, HEADER_LANGUAGE_MENU_CLICKED } from '../../constants/events'; +import { useTypedSelector } from '../../hooks/store'; +import { locales } from '../../i18n/config'; +import { setUserLocale } from '../../i18n/service'; +import logEvent, { getEventUserData } from '../../utils/logEvent'; +import Link from '../common/Link'; + +const menuItemStyle = { + ':hover': { backgroundColor: 'transparent' }, + '& .MuiTouchRipple-root span': { + backgroundColor: 'transparent', + }, +} as const; + +const languageMap: { [key: string]: string } = { + en: 'English', + hi: 'Hindi', + pt: 'Português', + es: 'Español', + de: 'Deutsch', + fr: 'Français', +}; + +const buttonStyle = { + height: 40, + minWidth: { xs: 40, md: 64 }, + paddingX: 1, + gap: 0.75, + fontWeight: 400, + color: 'common.white', + ':hover': { backgroundColor: 'primary.light', color: 'primary.dark' }, + + '& .MuiTouchRipple-root span': { + backgroundColor: 'primary.main', + opacity: 0.2, + }, + '& .MuiButton-startIcon': { display: 'inline-flex', mx: 0 }, +} as const; + +export default function LanguageMenuAppRoute() { + const locale = useLocale(); + const pathname = usePathname(); + const t = useTranslations('Navigation'); + const userCreatedAt = useTypedSelector((state) => state.user.createdAt); + const partnerAccesses = useTypedSelector((state) => state.partnerAccesses); + const partnerAdmin = useTypedSelector((state) => state.partnerAdmin); + const eventUserData = getEventUserData(userCreatedAt, partnerAccesses, partnerAdmin); + + const [anchorEl, setAnchorEl] = React.useState<null | HTMLElement>(null); + const open = Boolean(anchorEl); + + const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => { + logEvent(HEADER_LANGUAGE_MENU_CLICKED, eventUserData); + setAnchorEl(event.currentTarget); + }; + const handleClose = () => { + setAnchorEl(null); + }; + + const pathnameLocale = pathname?.replace(/^\/+/, '').split('/')[0] ?? ''; + + let pathnameWithoutLocale = pathname; + if (locales.includes(pathnameLocale)) { + pathnameWithoutLocale = pathname?.slice(3) ?? '/'; + } + + return ( + <Box> + <Button + qa-id="language-menu-button" + aria-controls="language-menu" + aria-haspopup="true" + aria-expanded={open ? 'true' : undefined} + aria-label={t('languageMenu')} + color="inherit" + onClick={handleClick} + startIcon={<LanguageIcon />} + size="medium" + sx={buttonStyle} + > + {languageMap[locale ? locale : 'en']} + </Button> + <Menu + anchorEl={anchorEl} + open={open} + onClose={handleClose} + elevation={1} + MenuListProps={{ + id: 'language-menu', + }} + > + {locales + ?.filter((language) => language !== locale) + .map((language) => { + const languageLabel = languageMap[language]; + return ( + <MenuItem key={language} sx={menuItemStyle}> + <Button + component={Link} + href={`/${language}${pathnameWithoutLocale}`} + locale={language} + onClick={() => { + setUserLocale(language, pathnameWithoutLocale as string); + logEvent(generateLanguageMenuEvent(language), eventUserData); + handleClose(); + }} + > + {languageLabel} + </Button> + </MenuItem> + ); + })} + </Menu> + </Box> + ); +} diff --git a/components/layout/NavigationMenu.tsx b/components/layout/NavigationMenu.tsx index d48a4347a..59ef3182b 100644 --- a/components/layout/NavigationMenu.tsx +++ b/components/layout/NavigationMenu.tsx @@ -1,6 +1,5 @@ import { List, ListItem, ListItemButton, ListItemText } from '@mui/material'; import { useTranslations } from 'next-intl'; -import { useRouter } from 'next/router'; import { Dispatch, SetStateAction, useEffect, useState } from 'react'; import { HEADER_ADMIN_CLICKED, @@ -69,7 +68,6 @@ const NavigationMenu = (props: NavigationMenuProps) => { const eventUserData = getEventUserData(userCreatedAt, partnerAccesses, partnerAdmin); const [navigationLinks, setNavigationLinks] = useState<Array<NavigationItem>>([]); - const router = useRouter(); useEffect(() => { let links: Array<NavigationItem> = []; diff --git a/components/layout/SecondaryNav.tsx b/components/layout/SecondaryNav.tsx index 37ce40f43..7a3cb326d 100644 --- a/components/layout/SecondaryNav.tsx +++ b/components/layout/SecondaryNav.tsx @@ -1,6 +1,6 @@ import { Icon, Tab, Tabs } from '@mui/material'; import Image from 'next/image'; -import { useRouter } from 'next/router'; +import { usePathname } from 'next/navigation'; import notesFromBloomIcon from '../../public/notes_from_bloom_icon.svg'; import therapyIcon from '../../public/therapy_icon.svg'; @@ -58,7 +58,7 @@ export const SecondaryNavIcon = ({ alt, src }: SecondaryNavIconType) => ( ); const SecondaryNav = ({ currentPage }: { currentPage: string }) => { - const router = useRouter(); + const pathname = usePathname(); const userCreatedAt = useTypedSelector((state) => state.user.createdAt); const partnerAccesses = useTypedSelector((state) => state.partnerAccesses); const partnerAdmin = useTypedSelector((state) => state.partnerAdmin); @@ -124,7 +124,7 @@ const SecondaryNav = ({ currentPage }: { currentPage: string }) => { }) : publicLinks; - const tabIndex = allLinks.findIndex((link) => link.href === router.asPath); + const tabIndex = allLinks.findIndex((link) => link.href === pathname); const tabValue = tabIndex === -1 ? false : tabIndex; return ( diff --git a/components/layout/TopBar.tsx b/components/layout/TopBar.tsx index 9d06a6c08..f878613a1 100644 --- a/components/layout/TopBar.tsx +++ b/components/layout/TopBar.tsx @@ -1,7 +1,7 @@ import { AppBar, Box, Button, Container, Theme, useMediaQuery, useTheme } from '@mui/material'; import { useTranslations } from 'next-intl'; import Image from 'next/image'; -import { useRouter } from 'next/router'; +import { usePathname } from 'next/navigation'; import { useEffect, useState } from 'react'; import { HEADER_HOME_LOGO_CLICKED, HEADER_LOGIN_CLICKED } from '../../constants/events'; import { useTypedSelector } from '../../hooks/store'; @@ -9,7 +9,6 @@ import bloomLogo from '../../public/bloom_logo_white.svg'; import { rowStyle } from '../../styles/common'; import logEvent, { getEventUserData } from '../../utils/logEvent'; import Link from '../common/Link'; -import LanguageMenu from './LanguageMenu'; import NavigationDrawer from './NavigationDrawer'; import NavigationMenu from './NavigationMenu'; import SecondaryNav from './SecondaryNav'; @@ -35,10 +34,10 @@ const logoContainerStyle = { height: 48, } as const; -const TopBar = () => { +const TopBar = ({ children }: { children: React.ReactNode }) => { const t = useTranslations('Navigation'); const tS = useTranslations('Shared'); - const router = useRouter(); + const pathname = usePathname(); const theme = useTheme(); const isSmallScreen = useMediaQuery(theme.breakpoints.down('md')); const [welcomeUrl, setWelcomeUrl] = useState<string>('/'); @@ -84,7 +83,7 @@ const TopBar = () => { <Box sx={{ ...rowStyle, alignItems: 'center', alignContent: 'center' }}> {!isSmallScreen && <NavigationMenu />} {userId && <UserMenu />} - <LanguageMenu /> + {children} {!isSmallScreen && !userId && ( <Button variant="contained" @@ -103,7 +102,7 @@ const TopBar = () => { {isSmallScreen && <NavigationDrawer />} </Box> </Container> - {!isSmallScreen && <SecondaryNav currentPage={router.pathname} />} + {!isSmallScreen && <SecondaryNav currentPage={pathname as string} />} </AppBar> </> ); diff --git a/components/storyblok/StoryblokCard.tsx b/components/storyblok/StoryblokCard.tsx index 32a107ee3..072b6c092 100644 --- a/components/storyblok/StoryblokCard.tsx +++ b/components/storyblok/StoryblokCard.tsx @@ -1,3 +1,5 @@ +'use client'; + import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown'; import { Box, diff --git a/components/storyblok/StoryblokCoursePage.tsx b/components/storyblok/StoryblokCoursePage.tsx index 494e6247c..46e8c752c 100644 --- a/components/storyblok/StoryblokCoursePage.tsx +++ b/components/storyblok/StoryblokCoursePage.tsx @@ -1,3 +1,5 @@ +'use client'; + import { Box, Container, Typography } from '@mui/material'; import { ISbRichtext, storyblokEditable } from '@storyblok/react'; import { useTranslations } from 'next-intl'; diff --git a/components/storyblok/StoryblokMeetTheTeamPage.tsx b/components/storyblok/StoryblokMeetTheTeamPage.tsx index b789f5b4e..8fa095c6d 100644 --- a/components/storyblok/StoryblokMeetTheTeamPage.tsx +++ b/components/storyblok/StoryblokMeetTheTeamPage.tsx @@ -1,3 +1,5 @@ +'use client'; + import { Box, Container, Typography } from '@mui/material'; import { ISbRichtext, storyblokEditable } from '@storyblok/react'; import Head from 'next/head'; diff --git a/components/storyblok/StoryblokPage.tsx b/components/storyblok/StoryblokPage.tsx index b7a6abd9c..678e18e96 100644 --- a/components/storyblok/StoryblokPage.tsx +++ b/components/storyblok/StoryblokPage.tsx @@ -1,3 +1,5 @@ +'use client'; + import { ISbRichtext, storyblokEditable } from '@storyblok/react'; import Head from 'next/head'; import { useRouter } from 'next/router'; diff --git a/components/storyblok/StoryblokSessionIbaPage.tsx b/components/storyblok/StoryblokSessionIbaPage.tsx index 96e1c619a..b5c0ffccd 100644 --- a/components/storyblok/StoryblokSessionIbaPage.tsx +++ b/components/storyblok/StoryblokSessionIbaPage.tsx @@ -1,3 +1,5 @@ +'use client'; + import ChatBubbleOutlineIcon from '@mui/icons-material/ChatBubbleOutline'; import CircleIcon from '@mui/icons-material/Circle'; import SlowMotionVideoIcon from '@mui/icons-material/SlowMotionVideo'; diff --git a/components/storyblok/StoryblokSessionPage.tsx b/components/storyblok/StoryblokSessionPage.tsx index e8c3d4747..3ea24dc1d 100644 --- a/components/storyblok/StoryblokSessionPage.tsx +++ b/components/storyblok/StoryblokSessionPage.tsx @@ -1,3 +1,5 @@ +'use client'; + import ChatBubbleOutlineIcon from '@mui/icons-material/ChatBubbleOutline'; import CircleIcon from '@mui/icons-material/Circle'; import LinkIcon from '@mui/icons-material/Link'; diff --git a/components/storyblok/StoryblokTeamMemberCard.tsx b/components/storyblok/StoryblokTeamMemberCard.tsx index c5f1dddab..7cedb97c0 100644 --- a/components/storyblok/StoryblokTeamMemberCard.tsx +++ b/components/storyblok/StoryblokTeamMemberCard.tsx @@ -127,7 +127,7 @@ const StoryblokTeamMemberCard = (props: StoryblokTeamMemberCardProps) => { </CardActionArea> <Collapse in={expanded} timeout="auto" unmountOnExit> <CardContent sx={collapseContentStyle}> - <Typography variant="body2" mb={0} paragraph> + <Typography variant="body2" mb={0} paragraph component={'div'}> {render(bio, RichTextOptions)} </Typography> </CardContent> diff --git a/components/storyblok/StoryblokWelcomePage.tsx b/components/storyblok/StoryblokWelcomePage.tsx index 39bf94dca..5c6569e1a 100644 --- a/components/storyblok/StoryblokWelcomePage.tsx +++ b/components/storyblok/StoryblokWelcomePage.tsx @@ -1,8 +1,10 @@ +'use client'; + import { Box, Button, Card, CardContent, Container, Typography } from '@mui/material'; import { ISbRichtext, storyblokEditable } from '@storyblok/react'; import { useTranslations } from 'next-intl'; import Head from 'next/head'; -import { useRouter } from 'next/router'; +import { useSearchParams } from 'next/navigation'; import { useEffect, useState } from 'react'; import { render } from 'storyblok-rich-text-react-renderer'; import Link from '../../components/common/Link'; @@ -100,7 +102,7 @@ const StoryblokWelcomePage = (props: StoryblokWelcomePageProps) => { }; const CallToActionCard = ({ partnerName }: { partnerName: string }) => { - const router = useRouter(); + const searchParams = useSearchParams(); const userId = useTypedSelector((state) => state.user.id); const userCreatedAt = useTypedSelector((state) => state.user.createdAt); @@ -123,9 +125,9 @@ const CallToActionCard = ({ partnerName }: { partnerName: string }) => { }, [partners, partnerName]); useEffect(() => { - const { code } = router.query; + const code = searchParams?.get('code'); if (code) setCodeParam(code + ''); - }, [setCodeParam, router.query]); + }, [setCodeParam, searchParams]); return ( <Card sx={rowItem}> diff --git a/cypress/integration/create-user-existing.cy.tsx b/cypress/integration/create-user-existing.cy.tsx index ae46359c6..c8bbddca2 100644 --- a/cypress/integration/create-user-existing.cy.tsx +++ b/cypress/integration/create-user-existing.cy.tsx @@ -11,7 +11,9 @@ describe('Create User', () => { // TODO put the correct home page check below when it is published // cy.get('h2', { timeout: 8000 }).contains('Get started').should('exist'); // TODO - workout why #primary-get-started-button works locallly and headless but not in github actions - cy.get('a[href="/auth/register"]', { timeout: 10000 }).first().click({ force: true }); + cy.get('a[qa-id="primary-get-started-button"]', { timeout: 10000 }) + .first() + .click({ force: true }); cy.wait(2000); cy.get('h2', { timeout: 10000 }).should('contain', 'Create account'); cy.get('#name', { timeout: 10000 }).type('Cypress test'); diff --git a/cypress/integration/create-user-incorrect-email.cy.tsx b/cypress/integration/create-user-incorrect-email.cy.tsx index 37e2a8b19..10c7b1e1a 100644 --- a/cypress/integration/create-user-incorrect-email.cy.tsx +++ b/cypress/integration/create-user-incorrect-email.cy.tsx @@ -11,7 +11,9 @@ describe('Create User', () => { // TODO put the correct home page check below when it is published // cy.get('h2', { timeout: 8000 }).contains('Get started').should('exist'); // TODO - workout why #primary-get-started-button works locallly and headless but not in github actions - cy.get('a[href="/auth/register"]', { timeout: 10000 }).first().click({ force: true }); + cy.get('a[qa-id="primary-get-started-button"]', { timeout: 10000 }) + .first() + .click({ force: true }); cy.wait(2000); cy.get('h2', { timeout: 10000 }).should('contain', 'Create account'); cy.get('#name', { timeout: 10000 }).type('Cypress test'); diff --git a/cypress/integration/create-user-weak-password.cy.tsx b/cypress/integration/create-user-weak-password.cy.tsx index c384ec337..f5d654534 100644 --- a/cypress/integration/create-user-weak-password.cy.tsx +++ b/cypress/integration/create-user-weak-password.cy.tsx @@ -11,7 +11,9 @@ describe('Create User', () => { // TODO put the correct home page check below when it is published // cy.get('h2', { timeout: 8000 }).contains('Get started').should('exist'); // TODO - workout why #primary-get-started-button works locallly and headless but not in github actions - cy.get('a[href="/auth/register"]', { timeout: 10000 }).first().click({ force: true }); + cy.get('a[qa-id="primary-get-started-button"]', { timeout: 10000 }) + .first() + .click({ force: true }); cy.wait(2000); cy.get('h2', { timeout: 10000 }).should('contain', 'Create account'); cy.get('#name', { timeout: 10000 }).type('Cypress test'); diff --git a/cypress/integration/create-user.cy.tsx b/cypress/integration/create-user.cy.tsx index 5e1fd03e4..0739fe1b7 100644 --- a/cypress/integration/create-user.cy.tsx +++ b/cypress/integration/create-user.cy.tsx @@ -13,7 +13,9 @@ describe('Create User', () => { // TODO put the correct home page check below when it is published // cy.get('h2', { timeout: 8000 }).contains('Get started').should('exist'); // TODO - workout why #primary-get-started-button works locallly and headless but not in github actions - cy.get('a[href="/auth/register"]', { timeout: 10000 }).first().click({ force: true }); + cy.get('a[qa-id="primary-get-started-button"]', { timeout: 10000 }) + .first() + .click({ force: true }); cy.wait(2000); cy.get('h2', { timeout: 10000 }).should('contain', 'Create account'); cy.get('#name', { timeout: 10000 }).type('Cypress test'); diff --git a/cypress/integration/create-user.de.cy.tsx b/cypress/integration/create-user.de.cy.tsx index a7bf11a03..a4affa334 100644 --- a/cypress/integration/create-user.de.cy.tsx +++ b/cypress/integration/create-user.de.cy.tsx @@ -11,7 +11,9 @@ describe('Create User', () => { cy.get('h1', { timeout: 8000 }) .contains('Begleite uns während deines Heilungsprozesses') .should('exist'); - cy.get('a[href="/de/auth/register"]', { timeout: 5000 }).first().click({ force: true }); + cy.get('a[qa-id="primary-get-started-button"]', { timeout: 5000 }) + .first() + .click({ force: true }); cy.wait(2000); cy.get('h2', { timeout: 8000 }).should('contain', 'Konto anlegen'); cy.get('#name', { timeout: 8000 }).type('Cypress test'); diff --git a/cypress/integration/create-user.es.cy.tsx b/cypress/integration/create-user.es.cy.tsx index a14996a94..c3ab2c45a 100644 --- a/cypress/integration/create-user.es.cy.tsx +++ b/cypress/integration/create-user.es.cy.tsx @@ -9,7 +9,9 @@ describe('Create User', () => { cy.visitSpanishPage('/'); cy.wait(2000); cy.get('h1', { timeout: 8000 }).contains('Acompáñanos en tu viaje de sanación').should('exist'); - cy.get('a[href="/es/auth/register"]', { timeout: 5000 }).first().click({ force: true }); + cy.get('a[qa-id="primary-get-started-button"]', { timeout: 5000 }) + .first() + .click({ force: true }); cy.wait(2000); cy.get('h2', { timeout: 8000 }).should('contain', 'Crea una cuenta'); cy.get('#name', { timeout: 8000 }).type('Cypress test'); diff --git a/cypress/integration/create-user.fr.cy.tsx b/cypress/integration/create-user.fr.cy.tsx index 5e1863d86..35ed4238e 100644 --- a/cypress/integration/create-user.fr.cy.tsx +++ b/cypress/integration/create-user.fr.cy.tsx @@ -11,7 +11,9 @@ describe('Create User', () => { cy.get('h1', { timeout: 8000 }) .contains('Rejoins-nous sur ton chemin de guérison') .should('exist'); - cy.get('a[href="/fr/auth/register"]', { timeout: 5000 }).first().click({ force: true }); + cy.get('a[qa-id="primary-get-started-button"]', { timeout: 5000 }) + .first() + .click({ force: true }); cy.wait(2000); cy.get('h2', { timeout: 8000 }).should('contain', 'Créer un compte'); cy.get('#name', { timeout: 8000 }).type('Cypress test'); diff --git a/cypress/integration/create-user.hi.cy.tsx b/cypress/integration/create-user.hi.cy.tsx index bca2db734..34b94e958 100644 --- a/cypress/integration/create-user.hi.cy.tsx +++ b/cypress/integration/create-user.hi.cy.tsx @@ -9,7 +9,9 @@ describe('Create User', () => { cy.visitHindiPage('/'); cy.wait(2000); - cy.get('a[href="/hi/auth/register"]', { timeout: 5000 }).first().click({ force: true }); + cy.get('a[qa-id="primary-get-started-button"]', { timeout: 5000 }) + .first() + .click({ force: true }); cy.wait(2000); cy.get('h2', { timeout: 8000 }).should('contain', 'Account banaiye'); cy.get('#name', { timeout: 8000 }).type('Cypress test'); diff --git a/cypress/integration/meet-the-team.cy.tsx b/cypress/integration/meet-the-team.cy.tsx new file mode 100644 index 000000000..457857a5a --- /dev/null +++ b/cypress/integration/meet-the-team.cy.tsx @@ -0,0 +1,36 @@ +describe('Meet The Team', () => { + before(() => { + cy.cleanUpTestState(); + }); + + it('User should be able to navigate to the meet the team in english', () => { + // Start from the home page + cy.visit('/'); + cy.wait(2000); + cy.get('a[qa-id="meet-team-menu-button"]', { timeout: 10000 }).first().click({ force: true }); + cy.wait(2000); + cy.get('h1', { timeout: 10000 }).should('contain', 'Our Bloom team'); + cy.get('p', { timeout: 10000 }).should( + 'contain', + 'Our team comes from all corners of the world. We deeply care about the experiences of survivors and the many ways in which our identity affects our experience of trauma, as many of us, are survivors ourselves', + ); + cy.get('h2', { timeout: 10000 }).should('contain', 'Core team'); + cy.get('h3', { timeout: 10000 }).should('contain', 'Our volunteers'); + cy.get('p', { timeout: 10000 }).should( + 'contain', + 'Chayn began as a volunteer-run network of survivors and allies that wanted to create a feminist space online for information, solidarity and healing. Our volunteers continue to play an important part in informing, reviewing and designing our services.', + ); + cy.get('h3', { timeout: 10000 }).should('contain', 'Our therapists'); + cy.get('p', { timeout: 10000 }).should( + 'contain', + 'We co-create our courses with a network of certified therapists who practice a trauma-informed and feminist approach. They are based all across the world, and speak many languages.', + ); + cy.get('h2', { timeout: 10000 }).should('contain', 'Supporting team'); + cy.get('p', { timeout: 10000 }).should( + 'contain', + 'Our wider team has helped us edit, write, and record our course videos, and some of them also provide support over 1-1 chat. They bring with them a wealth of knowledge and life experience, and have been trained by Chayn. You may even come across someone who is based in the same place as you!', + ); + cy.get('h2', { timeout: 10000 }).should('contain', 'Our research and awards'); + cy.get('h2', { timeout: 10000 }).should('contain', 'Funded by'); + }); +}); diff --git a/guards/AuthGuard.tsx b/guards/AuthGuard.tsx index c8af789bd..d3667e809 100644 --- a/guards/AuthGuard.tsx +++ b/guards/AuthGuard.tsx @@ -1,9 +1,10 @@ 'use client'; -import { useRouter } from 'next/router'; +import { usePathname, useRouter } from 'next/navigation'; import LoadingContainer from '../components/common/LoadingContainer'; import { useTypedSelector } from '../hooks/store'; import useLoadUser from '../hooks/useLoadUser'; +import { locales } from '../i18n/config'; import { default as generateReturnUrlQuery } from '../utils/generateReturnQuery'; import { PartnerAdminGuard } from './PartnerAdminGuard'; import { SuperAdminGuard } from './SuperAdminGuard'; @@ -37,6 +38,7 @@ const partiallyPublicPages = [ // New pages will default to requiring authenticated and public pages must be added to the array above export function AuthGuard({ children }: { children: JSX.Element }) { const router = useRouter(); + const pathname = usePathname() as string; const userId = useTypedSelector((state) => state.user.id); const userLoading = useTypedSelector((state) => state.user.loading); const userAuthLoading = useTypedSelector((state) => state.user.authStateLoading); @@ -44,18 +46,23 @@ export function AuthGuard({ children }: { children: JSX.Element }) { const { userResourceError } = useLoadUser(); const unauthenticated = userResourceError || (!userAuthLoading && !userLoading && !userId); + const pathnameParts = pathname?.split('/') ?? ['/']; + const pathNamePartsWithoutLocale = pathnameParts.filter((part) => !locales.includes(part)); // Get top level directory of path e.g pathname /courses/course_name has pathHead courses - const pathHead = router.pathname.split('/')[1]; // E.g. courses | therapy | partner-admin + const pathHead = pathNamePartsWithoutLocale?.[1] ?? '/'; // E.g. courses | therapy | partner-admin // Page does not require authenticated user, return content without guards - if (publicPathHeads.includes(pathHead) || partiallyPublicPages.includes(router.asPath)) { + if ( + publicPathHeads.includes(pathHead) || + partiallyPublicPages.includes(pathNamePartsWithoutLocale.join('/')) + ) { return <>{children}</>; } // Page requires authenticated user if (unauthenticated) { if (typeof window !== 'undefined') { - router.push(`/auth/login${generateReturnUrlQuery(router.asPath)}`); + router.push(`/auth/login${generateReturnUrlQuery(pathname)}`); } } diff --git a/i18n/config.ts b/i18n/config.ts new file mode 100644 index 000000000..827ce1889 --- /dev/null +++ b/i18n/config.ts @@ -0,0 +1,6 @@ +export type Locale = (typeof locales)[number]; +export const COOKIE_LOCALE_NAME = 'NEXT_LOCALE'; +export const COOKIE_LOCALE_PATH = '/'; + +export const locales = ['en', 'es', 'hi', 'fr', 'pt', 'de']; +export const defaultLocale: Locale = 'en'; diff --git a/i18n/i18n.ts b/i18n/i18n.ts new file mode 100644 index 000000000..d0e06367b --- /dev/null +++ b/i18n/i18n.ts @@ -0,0 +1,28 @@ +import { getRequestConfig } from 'next-intl/server'; +import { notFound } from 'next/navigation'; +import { locales } from './config'; + +export default getRequestConfig(async ({ locale }) => { + console.log('I18N', locale); + // Validate that the incoming `locale` parameter is valid + if (!locales.includes(locale as any)) notFound(); + + return { + locale, + messages: { + ...(await import(`../messages/shared/${locale}.json`)).default, + ...(await import(`../messages/navigation/${locale}.json`)).default, + ...(await import(`../messages/account/${locale}.json`)).default, + ...(await import(`../messages/auth/${locale}.json`)).default, + ...(await import(`../messages/partnerAdmin/${locale}.json`)).default, + ...(await import(`../messages/admin/${locale}.json`)).default, + ...(await import(`../messages/partnership/${locale}.json`)).default, + ...(await import(`../messages/welcome/${locale}.json`)).default, + ...(await import(`../messages/courses/${locale}.json`)).default, + ...(await import(`../messages/therapy/${locale}.json`)).default, + ...(await import(`../messages/chat/${locale}.json`)).default, + ...(await import(`../messages/whatsapp/${locale}.json`)).default, + }, + timeZone: 'Europe/London', + }; +}); diff --git a/i18n/navigation.ts b/i18n/navigation.ts new file mode 100644 index 000000000..19d487e75 --- /dev/null +++ b/i18n/navigation.ts @@ -0,0 +1,6 @@ +import { createSharedPathnamesNavigation } from 'next-intl/navigation'; +import { locales } from './config'; + +export const { Link, redirect, usePathname, useRouter } = createSharedPathnamesNavigation({ + locales, +}); diff --git a/i18n/service.ts b/i18n/service.ts new file mode 100644 index 000000000..577445da0 --- /dev/null +++ b/i18n/service.ts @@ -0,0 +1,17 @@ +'use server'; + +import { revalidatePath } from 'next/cache'; +import { cookies } from 'next/headers'; +import { COOKIE_LOCALE_NAME, COOKIE_LOCALE_PATH, Locale, defaultLocale } from './config'; + +// In this example the locale is read from a cookie. You could alternatively +// also read it from a database, backend service, or any other source. +export async function getUserLocale() { + return cookies().get(COOKIE_LOCALE_NAME)?.value || defaultLocale; +} + +export async function setUserLocale(locale: Locale, path: string = '/') { + cookies().set(COOKIE_LOCALE_NAME, locale, { path: COOKIE_LOCALE_PATH }); + + revalidatePath(path); +} diff --git a/middleware.ts b/middleware.ts new file mode 100644 index 000000000..b9ce69ebc --- /dev/null +++ b/middleware.ts @@ -0,0 +1,13 @@ +import createMiddleware from 'next-intl/middleware'; +import { locales } from './i18n/config'; + +export default createMiddleware({ + locales, + defaultLocale: 'en', + localePrefix: 'as-needed', +}); + +export const config = { + // Skip all paths that should not be internationalized + matcher: ['/((?!api|_next|_vercel|.*\\..*).*)', '/'], +}; diff --git a/next-env.d.ts b/next-env.d.ts index 4f11a03dc..fd36f9494 100644 --- a/next-env.d.ts +++ b/next-env.d.ts @@ -1,5 +1,6 @@ /// <reference types="next" /> /// <reference types="next/image-types/global" /> +/// <reference types="next/navigation-types/compat/navigation" /> // NOTE: This file should not be edited // see https://nextjs.org/docs/basic-features/typescript for more information. diff --git a/next.config.js b/next.config.js index 057428437..99552457f 100644 --- a/next.config.js +++ b/next.config.js @@ -14,67 +14,92 @@ const withBundleAnalyzer = require('@next/bundle-analyzer')({ enabled: process.env.ANALYZE === 'true', }); -module.exports = withBundleAnalyzer( - withPWA({ - reactStrictMode: true, - publicRuntimeConfig: { - NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL, - NEXT_PUBLIC_BASE_URL: process.env.NEXT_PUBLIC_BASE_URL, - NEXT_PUBLIC_VERCEL_BRANCH_URL: process.env.NEXT_PUBLIC_VERCEL_BRANCH_URL, - NEXT_PUBLIC_ENV: process.env.NEXT_PUBLIC_ENV, - NEXT_PUBLIC_VERCEL_GIT_COMMIT_SHA: process.env.NEXT_PUBLIC_VERCEL_GIT_COMMIT_SHA, - NEXT_PUBLIC_ROLLBAR_CLIENT_TOKEN: process.env.NEXT_PUBLIC_ROLLBAR_CLIENT_TOKEN, - NEXT_PUBLIC_SIMPLYBOOK_WIDGET_URL: process.env.NEXT_PUBLIC_SIMPLYBOOK_WIDGET_URL, - NEXT_PUBLIC_CRISP_WEBSITE_ID: process.env.NEXT_PUBLIC_CRISP_WEBSITE_ID, - NEXT_PUBLIC_HOTJAR_ID: process.env.NEXT_PUBLIC_HOTJAR_ID, - NEXT_PUBLIC_FIREBASE_MEASUREMENT_ID: process.env.NEXT_PUBLIC_FIREBASE_MEASUREMENT_ID, - NEXT_PUBLIC_GOOGLE_TAG_MANAGER_ID: process.env.NEXT_PUBLIC_GOOGLE_TAG_MANAGER_ID, - }, - compiler: { - emotion: true, - }, - images: { - domains: ['a.storyblok.com'], - }, - i18n: { - locales: ['en', 'es', 'hi', 'fr', 'pt', 'de'], - defaultLocale: 'en', - localeDetection: true, - }, - async redirects() { - return [ - { - source: '/welcome', - destination: '/courses', - permanent: false, - }, - { - source: '/login', - destination: '/courses', - permanent: false, - }, - { - source: '/about-our-courses', - destination: '/courses', - permanent: false, - }, - ]; - }, - async rewrites() { - return [ - { - source: '/welcome/(b|B)(a|A)(d|D)(o|O)(o|O)', - destination: '/welcome/badoo', - }, - { - source: '/welcome/(b|B)(u|U)(m|M)(b|B)(l|L)(e|E)', - destination: '/welcome/bumble', - }, - { - source: '/welcome/(f|F)(r|R)(u|U)(i|I)(t|T)(z|Z)', - destination: '/welcome/fruitz', - }, - ]; - }, - }), +// Configuration according to Newrelic app router example +// See https://github.com/newrelic/newrelic-node-nextjs?tab=readme-ov-file#example-projects +const nrExternals = require('@newrelic/next/load-externals'); + +// Configuration according to next-intl app router migration example +// See https://next-intl-docs.vercel.app/examples#app-router-migration +// See https://github.com/amannn/next-intl/tree/main/examples/example-app-router-migration +// Middleware has been hacked due to not being able to use a [locale] segment +const withNextIntl = require('next-intl/plugin')('./i18n/i18n.ts'); + +module.exports = withNextIntl( + withBundleAnalyzer( + withPWA({ + experimental: { + // Without this setting, the Next.js compilation step will routinely + // try to import files such as `LICENSE` from the `newrelic` module. + // See https://nextjs.org/docs/app/api-reference/next-config-js/serverComponentsExternalPackages. + serverComponentsExternalPackages: ['newrelic'], + }, + // In order for newrelic to effectively instrument a Next.js application, + // the modules that newrelic supports should not be mangled by webpack. Thus, + // we need to "externalize" all of the modules that newrelic supports. + webpack: (config) => { + nrExternals(config); + return config; + }, + reactStrictMode: true, + publicRuntimeConfig: { + NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL, + NEXT_PUBLIC_BASE_URL: process.env.NEXT_PUBLIC_BASE_URL, + NEXT_PUBLIC_VERCEL_BRANCH_URL: process.env.NEXT_PUBLIC_VERCEL_BRANCH_URL, + NEXT_PUBLIC_ENV: process.env.NEXT_PUBLIC_ENV, + NEXT_PUBLIC_VERCEL_GIT_COMMIT_SHA: process.env.NEXT_PUBLIC_VERCEL_GIT_COMMIT_SHA, + NEXT_PUBLIC_ROLLBAR_CLIENT_TOKEN: process.env.NEXT_PUBLIC_ROLLBAR_CLIENT_TOKEN, + NEXT_PUBLIC_SIMPLYBOOK_WIDGET_URL: process.env.NEXT_PUBLIC_SIMPLYBOOK_WIDGET_URL, + NEXT_PUBLIC_CRISP_WEBSITE_ID: process.env.NEXT_PUBLIC_CRISP_WEBSITE_ID, + NEXT_PUBLIC_HOTJAR_ID: process.env.NEXT_PUBLIC_HOTJAR_ID, + NEXT_PUBLIC_FIREBASE_MEASUREMENT_ID: process.env.NEXT_PUBLIC_FIREBASE_MEASUREMENT_ID, + NEXT_PUBLIC_GOOGLE_TAG_MANAGER_ID: process.env.NEXT_PUBLIC_GOOGLE_TAG_MANAGER_ID, + }, + compiler: { + emotion: true, + }, + images: { + domains: ['a.storyblok.com'], + }, + async redirects() { + return [ + { + source: '/welcome', + destination: '/courses', + permanent: false, + }, + { + source: '/login', + destination: '/courses', + permanent: false, + }, + { + source: '/about-our-courses', + destination: '/courses', + permanent: false, + }, + { + source: '/home', + destination: '/', + permanent: false, + }, + ]; + }, + async rewrites() { + return [ + { + source: '/welcome/(b|B)(a|A)(d|D)(o|O)(o|O)', + destination: '/welcome/badoo', + }, + { + source: '/welcome/(b|B)(u|U)(m|M)(b|B)(l|L)(e|E)', + destination: '/welcome/bumble', + }, + { + source: '/welcome/(f|F)(r|R)(u|U)(i|I)(t|T)(z|Z)', + destination: '/welcome/fruitz', + }, + ]; + }, + }), + ), ); diff --git a/package.json b/package.json index 681871035..a399867b0 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "@mui/icons-material": "^5.16.0", "@mui/lab": "^5.0.0-alpha.171", "@mui/material": "^5.16.1", + "@mui/material-nextjs": "^5.16.4", "@newrelic/next": "^0.10.0", "@reduxjs/toolkit": "^2.2.5", "@storyblok/react": "^3.0.0", @@ -50,6 +51,7 @@ "nuka-carousel": "^7.0.0", "phone": "^3.1.33", "react": "^18.3.1", + "react-cookie": "^7.2.0", "react-cookie-consent": "^8.0.1", "react-dom": "^18.3.1", "react-international-phone": "^4.0.4", diff --git a/pages/[slug].tsx b/pages/[locale]/[slug].tsx similarity index 75% rename from pages/[slug].tsx rename to pages/[locale]/[slug].tsx index 9cd4625f4..ae9c2bb5a 100644 --- a/pages/[slug].tsx +++ b/pages/[locale]/[slug].tsx @@ -1,8 +1,8 @@ import { ISbStoryData, getStoryblokApi, useStoryblokState } from '@storyblok/react'; import { GetStaticPathsContext, GetStaticPropsContext, NextPage } from 'next'; -import NoDataAvailable from '../components/common/NoDataAvailable'; -import StoryblokPage, { StoryblokPageProps } from '../components/storyblok/StoryblokPage'; -import { getStoryblokPageProps } from '../utils/getStoryblokPageProps'; +import NoDataAvailable from '../../components/common/NoDataAvailable'; +import StoryblokPage, { StoryblokPageProps } from '../../components/storyblok/StoryblokPage'; +import { getStoryblokPageProps } from '../../utils/getStoryblokPageProps'; interface Props { story: ISbStoryData | null; @@ -18,7 +18,8 @@ const Page: NextPage<Props> = ({ story }) => { return <StoryblokPage {...(story.content as StoryblokPageProps)} />; }; -export async function getStaticProps({ locale, preview = false, params }: GetStaticPropsContext) { +export async function getStaticProps({ preview = false, params }: GetStaticPropsContext) { + const locale = params?.locale as string; const slug = params?.slug instanceof Array ? params.slug.join('/') : params?.slug; const storyblokProps = await getStoryblokPageProps(slug, locale, preview); @@ -27,8 +28,8 @@ export async function getStaticProps({ locale, preview = false, params }: GetSta props: { ...storyblokProps, messages: { - ...require(`../messages/shared/${locale}.json`), - ...require(`../messages/navigation/${locale}.json`), + ...require(`../../messages/shared/${locale}.json`), + ...require(`../../messages/navigation/${locale}.json`), }, }, revalidate: 3600, // revalidate every hour diff --git a/pages/about-our-courses.tsx b/pages/[locale]/about-our-courses.tsx similarity index 81% rename from pages/about-our-courses.tsx rename to pages/[locale]/about-our-courses.tsx index b1a587787..e236f4bef 100644 --- a/pages/about-our-courses.tsx +++ b/pages/[locale]/about-our-courses.tsx @@ -3,13 +3,13 @@ import { ISbStoryData, useStoryblokState } from '@storyblok/react'; import { GetStaticPropsContext, NextPage } from 'next'; import Head from 'next/head'; import { useEffect } from 'react'; -import NoDataAvailable from '../components/common/NoDataAvailable'; -import Header from '../components/layout/Header'; -import StoryblokPageSection from '../components/storyblok/StoryblokPageSection'; -import { ABOUT_COURSES_VIEWED } from '../constants/events'; -import { useTypedSelector } from '../hooks/store'; -import { getStoryblokPageProps } from '../utils/getStoryblokPageProps'; -import logEvent, { getEventUserData } from '../utils/logEvent'; +import NoDataAvailable from '../../components/common/NoDataAvailable'; +import Header from '../../components/layout/Header'; +import StoryblokPageSection from '../../components/storyblok/StoryblokPageSection'; +import { ABOUT_COURSES_VIEWED } from '../../constants/events'; +import { useTypedSelector } from '../../hooks/store'; +import { getStoryblokPageProps } from '../../utils/getStoryblokPageProps'; +import logEvent, { getEventUserData } from '../../utils/logEvent'; interface Props { story: ISbStoryData | null; diff --git a/pages/account/about-you.tsx b/pages/[locale]/account/about-you.tsx similarity index 100% rename from pages/account/about-you.tsx rename to pages/[locale]/account/about-you.tsx diff --git a/pages/account/apply-a-code.tsx b/pages/[locale]/account/apply-a-code.tsx similarity index 100% rename from pages/account/apply-a-code.tsx rename to pages/[locale]/account/apply-a-code.tsx diff --git a/pages/account/disable-service-emails.tsx b/pages/[locale]/account/disable-service-emails.tsx similarity index 100% rename from pages/account/disable-service-emails.tsx rename to pages/[locale]/account/disable-service-emails.tsx diff --git a/pages/account/settings.tsx b/pages/[locale]/account/settings.tsx similarity index 100% rename from pages/account/settings.tsx rename to pages/[locale]/account/settings.tsx diff --git a/pages/action-handler.tsx b/pages/[locale]/action-handler.tsx similarity index 100% rename from pages/action-handler.tsx rename to pages/[locale]/action-handler.tsx diff --git a/pages/admin/dashboard.tsx b/pages/[locale]/admin/dashboard.tsx similarity index 100% rename from pages/admin/dashboard.tsx rename to pages/[locale]/admin/dashboard.tsx diff --git a/pages/auth/login.tsx b/pages/[locale]/auth/login.tsx similarity index 79% rename from pages/auth/login.tsx rename to pages/[locale]/auth/login.tsx index 497eea001..67b7d0bf3 100644 --- a/pages/auth/login.tsx +++ b/pages/[locale]/auth/login.tsx @@ -7,28 +7,28 @@ import { useMediaQuery, useTheme, } from '@mui/material'; -import type { NextPage } from 'next'; +import type { GetStaticPathsContext, NextPage } from 'next'; import { GetStaticPropsContext } from 'next'; import { useTranslations } from 'next-intl'; import Head from 'next/head'; import Image from 'next/image'; import { useRouter } from 'next/router'; import { useEffect } from 'react'; -import Link from '../../components/common/Link'; -import LoginForm from '../../components/forms/LoginForm'; -import PartnerHeader from '../../components/layout/PartnerHeader'; +import Link from '../../../components/common/Link'; +import LoginForm from '../../../components/forms/LoginForm'; +import PartnerHeader from '../../../components/layout/PartnerHeader'; import { GET_STARTED_WITH_BLOOM_CLICKED, RESET_PASSWORD_HERE_CLICKED, generateGetStartedPartnerEvent, -} from '../../constants/events'; -import { getAllPartnersContent } from '../../constants/partners'; -import { useTypedSelector } from '../../hooks/store'; -import illustrationBloomHeadYellow from '../../public/illustration_bloom_head_yellow.svg'; -import illustrationLeafMix from '../../public/illustration_leaf_mix.svg'; -import welcomeToBloom from '../../public/welcome_to_bloom.svg'; -import { rowStyle } from '../../styles/common'; -import logEvent, { getEventUserData } from '../../utils/logEvent'; +} from '../../../constants/events'; +import { getAllPartnersContent } from '../../../constants/partners'; +import { useTypedSelector } from '../../../hooks/store'; +import illustrationBloomHeadYellow from '../../../public/illustration_bloom_head_yellow.svg'; +import illustrationLeafMix from '../../../public/illustration_leaf_mix.svg'; +import welcomeToBloom from '../../../public/welcome_to_bloom.svg'; +import { rowStyle } from '../../../styles/common'; +import logEvent, { getEventUserData } from '../../../utils/logEvent'; const containerStyle = { ...rowStyle, @@ -179,16 +179,27 @@ const Login: NextPage = () => { ); }; -export function getStaticProps({ locale }: GetStaticPropsContext) { +export function getStaticProps({ params }: GetStaticPropsContext) { + const locale = params?.locale as string; return { props: { messages: { - ...require(`../../messages/shared/${locale}.json`), - ...require(`../../messages/navigation/${locale}.json`), - ...require(`../../messages/auth/${locale}.json`), + ...require(`../../../messages/shared/${locale}.json`), + ...require(`../../../messages/navigation/${locale}.json`), + ...require(`../../../messages/auth/${locale}.json`), }, }, }; } +export async function getStaticPaths({ locales }: GetStaticPathsContext) { + const paths = [{ params: { locale: 'en' } }, { params: { locale: 'es' } }]; + + console.log(paths); + return { + paths, + fallback: false, + }; +} + export default Login; diff --git a/pages/auth/register.tsx b/pages/[locale]/auth/register.tsx similarity index 100% rename from pages/auth/register.tsx rename to pages/[locale]/auth/register.tsx diff --git a/pages/auth/reset-password.tsx b/pages/[locale]/auth/reset-password.tsx similarity index 100% rename from pages/auth/reset-password.tsx rename to pages/[locale]/auth/reset-password.tsx diff --git a/pages/courses/[slug].tsx b/pages/[locale]/courses/[slug].tsx similarity index 100% rename from pages/courses/[slug].tsx rename to pages/[locale]/courses/[slug].tsx diff --git a/pages/courses/[slug]/[sessionSlug].tsx b/pages/[locale]/courses/[slug]/[sessionSlug].tsx similarity index 100% rename from pages/courses/[slug]/[sessionSlug].tsx rename to pages/[locale]/courses/[slug]/[sessionSlug].tsx diff --git a/pages/courses/image-based-abuse-and-rebuilding-ourselves/[sessionSlug].tsx b/pages/[locale]/courses/image-based-abuse-and-rebuilding-ourselves/[sessionSlug].tsx similarity index 100% rename from pages/courses/image-based-abuse-and-rebuilding-ourselves/[sessionSlug].tsx rename to pages/[locale]/courses/image-based-abuse-and-rebuilding-ourselves/[sessionSlug].tsx diff --git a/pages/courses/image-based-abuse-and-rebuilding-ourselves/index.tsx b/pages/[locale]/courses/image-based-abuse-and-rebuilding-ourselves/index.tsx similarity index 100% rename from pages/courses/image-based-abuse-and-rebuilding-ourselves/index.tsx rename to pages/[locale]/courses/image-based-abuse-and-rebuilding-ourselves/index.tsx diff --git a/pages/courses/index.tsx b/pages/[locale]/courses/index.tsx similarity index 100% rename from pages/courses/index.tsx rename to pages/[locale]/courses/index.tsx diff --git a/pages/index.tsx b/pages/[locale]/index.tsx similarity index 65% rename from pages/index.tsx rename to pages/[locale]/index.tsx index e2e988696..6bb44fa50 100644 --- a/pages/index.tsx +++ b/pages/[locale]/index.tsx @@ -1,18 +1,18 @@ import { Box, Button } from '@mui/material'; import { ISbStoryData, useStoryblokState } from '@storyblok/react'; -import type { NextPage } from 'next'; +import type { GetStaticPathsContext, NextPage } from 'next'; import { GetStaticPropsContext } from 'next'; import { useTranslations } from 'next-intl'; import Head from 'next/head'; import { useEffect, useState } from 'react'; -import Link from '../components/common/Link'; -import NoDataAvailable from '../components/common/NoDataAvailable'; -import HomeHeader from '../components/layout/HomeHeader'; -import StoryblokPageSection from '../components/storyblok/StoryblokPageSection'; -import { PROMO_GET_STARTED_CLICKED } from '../constants/events'; -import { useTypedSelector } from '../hooks/store'; -import { getStoryblokPageProps } from '../utils/getStoryblokPageProps'; -import logEvent, { getEventUserData } from '../utils/logEvent'; +import Link from '../../components/common/Link'; +import NoDataAvailable from '../../components/common/NoDataAvailable'; +import HomeHeader from '../../components/layout/HomeHeader'; +import StoryblokPageSection from '../../components/storyblok/StoryblokPageSection'; +import { PROMO_GET_STARTED_CLICKED } from '../../constants/events'; +import { useTypedSelector } from '../../hooks/store'; +import { getStoryblokPageProps } from '../../utils/getStoryblokPageProps'; +import logEvent, { getEventUserData } from '../../utils/logEvent'; interface Props { story: ISbStoryData | null; @@ -55,7 +55,7 @@ const Index: NextPage<Props> = ({ story, preview }) => { translatedImageAlt={story.content.translatedImageAlt} cta={ <Button - id="primary-get-started-button" + qa-id="primary-get-started-button" sx={{ mt: 3 }} variant="contained" component={Link} @@ -78,22 +78,32 @@ const Index: NextPage<Props> = ({ story, preview }) => { ); }; -export async function getStaticProps({ locale, preview = false }: GetStaticPropsContext) { +export async function getStaticProps({ params, preview = false }: GetStaticPropsContext) { + const locale = params?.locale as string; const storyblokProps = await getStoryblokPageProps('home', locale, preview); return { props: { ...storyblokProps, messages: { - ...require(`../messages/shared/${locale}.json`), - ...require(`../messages/navigation/${locale}.json`), - ...require(`../messages/welcome/${locale}.json`), - ...require(`../messages/courses/${locale}.json`), - ...require(`../messages/chat/${locale}.json`), + ...require(`../../messages/shared/${locale}.json`), + ...require(`../../messages/navigation/${locale}.json`), + ...require(`../../messages/welcome/${locale}.json`), + ...require(`../../messages/courses/${locale}.json`), + ...require(`../../messages/chat/${locale}.json`), }, }, revalidate: 3600, // revalidate every hour }; } +export async function getStaticPaths({ locales }: GetStaticPathsContext) { + const paths = [{ params: { locale: 'en' } }, { params: { locale: 'es' } }]; + + return { + paths, + fallback: false, + }; +} + export default Index; diff --git a/pages/partner-admin/create-access-code.tsx b/pages/[locale]/partner-admin/create-access-code.tsx similarity index 100% rename from pages/partner-admin/create-access-code.tsx rename to pages/[locale]/partner-admin/create-access-code.tsx diff --git a/pages/partnership/[partnerName].tsx b/pages/[locale]/partnership/[partnerName].tsx similarity index 64% rename from pages/partnership/[partnerName].tsx rename to pages/[locale]/partnership/[partnerName].tsx index 0831649aa..440d51ae7 100644 --- a/pages/partnership/[partnerName].tsx +++ b/pages/[locale]/partnership/[partnerName].tsx @@ -5,15 +5,16 @@ import { getStoryblokApi, useStoryblokState, } from '@storyblok/react'; -import { GetStaticPathsContext, GetStaticPropsContext, NextPage } from 'next'; +import { GetStaticPropsContext, NextPage } from 'next'; import Head from 'next/head'; -import NoDataAvailable from '../../components/common/NoDataAvailable'; -import PartnerHeader from '../../components/layout/PartnerHeader'; -import StoryblokPageSection from '../../components/storyblok/StoryblokPageSection'; -import { PartnerContent, getPartnerContent } from '../../constants/partners'; -import illustrationBloomHeadYellow from '../../public/illustration_bloom_head_yellow.svg'; -import welcomeToBloom from '../../public/welcome_to_bloom.svg'; -import { getStoryblokPageProps } from '../../utils/getStoryblokPageProps'; +import NoDataAvailable from '../../../components/common/NoDataAvailable'; +import PartnerHeader from '../../../components/layout/PartnerHeader'; +import StoryblokPageSection from '../../../components/storyblok/StoryblokPageSection'; +import { PartnerContent, getPartnerContent } from '../../../constants/partners'; +import { locales } from '../../../i18n/config'; +import illustrationBloomHeadYellow from '../../../public/illustration_bloom_head_yellow.svg'; +import welcomeToBloom from '../../../public/welcome_to_bloom.svg'; +import { getStoryblokPageProps } from '../../../utils/getStoryblokPageProps'; interface Props { story: ISbStoryData | null; @@ -48,7 +49,8 @@ const Partnership: NextPage<Props> = ({ story }) => { ); }; -export async function getStaticProps({ locale, preview = false, params }: GetStaticPropsContext) { +export async function getStaticProps({ preview = false, params }: GetStaticPropsContext) { + const locale = params?.locale as string; const partnerName = params?.partnerName; const storyblokProps = await getStoryblokPageProps(`partnership/${partnerName}`, locale, preview); @@ -57,16 +59,16 @@ export async function getStaticProps({ locale, preview = false, params }: GetSta props: { ...storyblokProps, messages: { - ...require(`../../messages/shared/${locale}.json`), - ...require(`../../messages/navigation/${locale}.json`), - ...require(`../../messages/partnership/${locale}.json`), + ...require(`../../../messages/shared/${locale}.json`), + ...require(`../../../messages/navigation/${locale}.json`), + ...require(`../../../messages/partnership/${locale}.json`), }, }, revalidate: 3600, // revalidate every hour }; } -export async function getStaticPaths({ locales }: GetStaticPathsContext) { +export async function getStaticPaths() { let sbParams: ISbStoriesParams = { published: true, starts_with: 'partnership/', @@ -86,7 +88,7 @@ export async function getStaticPaths({ locales }: GetStaticPathsContext) { if (locales) { // create additional languages for (const locale of locales) { - paths.push({ params: { partnerName: splittedSlug[1] }, locale }); + paths.push({ params: { partnerName: splittedSlug[1], locale } }); } } }); diff --git a/pages/subscription/whatsapp.tsx b/pages/[locale]/subscription/whatsapp.tsx similarity index 100% rename from pages/subscription/whatsapp.tsx rename to pages/[locale]/subscription/whatsapp.tsx diff --git a/pages/therapy/book-session.tsx b/pages/[locale]/therapy/book-session.tsx similarity index 100% rename from pages/therapy/book-session.tsx rename to pages/[locale]/therapy/book-session.tsx diff --git a/pages/therapy/confirmed-session.tsx b/pages/[locale]/therapy/confirmed-session.tsx similarity index 100% rename from pages/therapy/confirmed-session.tsx rename to pages/[locale]/therapy/confirmed-session.tsx diff --git a/pages/_app.tsx b/pages/_app.tsx index c5efb559e..756e6b7c8 100644 --- a/pages/_app.tsx +++ b/pages/_app.tsx @@ -7,7 +7,7 @@ import { NextComponentType } from 'next'; import { NextIntlClientProvider } from 'next-intl'; import type { AppProps } from 'next/app'; import Head from 'next/head'; -import { useRouter } from 'next/router'; +import { NextRouter, withRouter } from 'next/router'; import { NextPageContext } from 'next/types'; import { Hotjar } from 'nextjs-hotjar'; import { useEffect } from 'react'; @@ -17,6 +17,7 @@ import { AppBarSpacer } from '../components/layout/AppBarSpacer'; import Consent from '../components/layout/Consent'; import ErrorBoundary from '../components/layout/ErrorBoundary'; import Footer from '../components/layout/Footer'; +import LanguageMenu from '../components/layout/LanguageMenu'; import LeaveSiteButton from '../components/layout/LeaveSiteButton'; import TopBar from '../components/layout/TopBar'; import createEmotionCache from '../config/emotionCache'; @@ -36,23 +37,64 @@ firebase; // Init storyblok storyblok; +type Props = AppProps & { + router: NextRouter; +}; + +const App = withRouter(({ Component, pageProps, router }: Props) => { + const locale = (router.query?.locale as string) ?? 'en'; + console.log(locale); + return ( + <ErrorBoundary> + <NextIntlClientProvider + locale={locale} + messages={pageProps.messages} + timeZone="Europe/Vienna" + > + <Head> + <title>Bloom</title> + <meta name="viewport" content="initial-scale=1, width=device-width" /> + </Head> + <CrispScript /> + <ThemeProvider theme={theme}> + <CssBaseline /> + <TopBar> + <LanguageMenu locale={locale} /> + </TopBar> + <AppBarSpacer /> + <AuthGuard> + <Component {...pageProps} /> + </AuthGuard> + <Footer /> + <Consent /> + {!!process.env.NEXT_PUBLIC_HOTJAR_ID && process.env.NEXT_PUBLIC_ENV !== 'local' && ( + <Hotjar id={process.env.NEXT_PUBLIC_HOTJAR_ID} sv={6} strategy="lazyOnload" /> + )} + {/* Vercel analytics */} + <Analytics /> + </ThemeProvider> + </NextIntlClientProvider> + </ErrorBoundary> + ); +}); + export interface MyAppProps extends AppProps { emotionCache?: EmotionCache; } -function MyApp(props: MyAppProps) { +const MyApp = withRouter((props: MyAppProps & { router: NextRouter }) => { const { Component, emotionCache = clientSideEmotionCache, pageProps, + router, }: { Component: NextComponentType<NextPageContext<any>, any, any>; emotionCache?: EmotionCache; pageProps: any; + router: NextRouter; } = props; - const router = useRouter(); - // Get top level directory of path e.g pathname /courses/course_name has pathHead courses const pathHead = router.pathname.split('/')[1]; // E.g. courses | therapy | partner-admin @@ -75,7 +117,7 @@ function MyApp(props: MyAppProps) { <ErrorBoundary> <NextIntlClientProvider messages={pageProps.messages} - locale={router.locale} + locale={(router.query?.locale as string) || 'en'} timeZone="Europe/London" > <Head> @@ -85,7 +127,9 @@ function MyApp(props: MyAppProps) { <CrispScript /> <ThemeProvider theme={theme}> <CssBaseline /> - <TopBar /> + <TopBar> + <LanguageMenu /> + </TopBar> <AppBarSpacer /> {pathHead !== 'partner-admin' && <LeaveSiteButton />} <AuthGuard> @@ -102,7 +146,7 @@ function MyApp(props: MyAppProps) { </NextIntlClientProvider> </ErrorBoundary> ); -} +}); function AppReduxWrapper({ Component, ...rest }: MyAppProps) { const { store, props } = wrapper.useWrappedStore(rest); @@ -117,7 +161,7 @@ function AppReduxWrapper({ Component, ...rest }: MyAppProps) { return ( <Provider store={store}> - <MyApp Component={Component} {...props} /> + <App Component={Component} {...props} /> </Provider> ); } diff --git a/pages/_document.tsx b/pages/_document.tsx index 0c73f3aa2..ddb7c7d78 100644 --- a/pages/_document.tsx +++ b/pages/_document.tsx @@ -22,10 +22,6 @@ export default class MyDocument extends Document<NewRelicProps> { type="text/javascript" dangerouslySetInnerHTML={{ __html: this.props.browserTimingHeader }} /> - <link - href="https://fonts.googleapis.com/css2?family=Montserrat:ital,wght@0,300;0,400;0,500;1,300;1,400;1,500&family=Open+Sans:ital,wght@0,300;0,400;0,600;1,300;1,400;1,600&display=swap" - rel="stylesheet" - /> <OpenGraphMetadata /> <GoogleTagManagerScript /> <RollbarScript /> diff --git a/pages/meet-the-team.tsx b/pages/meet-the-team.tsx deleted file mode 100644 index 385eea0eb..000000000 --- a/pages/meet-the-team.tsx +++ /dev/null @@ -1,59 +0,0 @@ -import { ISbStoryData, useStoryblokState } from '@storyblok/react'; -import { GetStaticPropsContext, NextPage } from 'next'; -import NoDataAvailable from '../components/common/NoDataAvailable'; -import StoryblokMeetTheTeamPage, { - StoryblokMeetTheTeamPageProps, -} from '../components/storyblok/StoryblokMeetTheTeamPage'; -import { columnStyle, rowStyle } from '../styles/common'; -import { getStoryblokPageProps } from '../utils/getStoryblokPageProps'; - -const coreContainerStyle = { - backgroundColor: 'secondary.light', -} as const; - -const supportingContainerStyle = { - backgroundColor: 'primary.light', -} as const; - -const cardColumnStyle = { - ...columnStyle, - justifyContent: 'flex-start', - width: { xs: '100%', sm: 'calc(50% - 1rem)' }, - gap: { xs: 0, sm: 2, md: 4 }, -} as const; - -const cardColumnRowStyle = { - ...rowStyle, - marginTop: { xs: 2, md: 5 }, -} as const; - -interface Props { - story: ISbStoryData | null; -} - -const MeetTheTeam: NextPage<Props> = ({ story }) => { - story = useStoryblokState(story); - - if (!story) { - return <NoDataAvailable />; - } - - return <StoryblokMeetTheTeamPage {...(story.content as StoryblokMeetTheTeamPageProps)} />; -}; - -export async function getStaticProps({ locale, preview = false }: GetStaticPropsContext) { - const storyblokProps = await getStoryblokPageProps('meet-the-team', locale, preview); - - return { - props: { - ...storyblokProps, - messages: { - ...require(`../messages/shared/${locale}.json`), - ...require(`../messages/navigation/${locale}.json`), - }, - }, - revalidate: 3600, // revalidate every hour - }; -} - -export default MeetTheTeam; diff --git a/pages/welcome/[partnerName].tsx b/pages/welcome/[partnerName].tsx deleted file mode 100644 index d4e360093..000000000 --- a/pages/welcome/[partnerName].tsx +++ /dev/null @@ -1,82 +0,0 @@ -import { - ISbStoriesParams, - ISbStoryData, - getStoryblokApi, - useStoryblokState, -} from '@storyblok/react'; -import type { GetStaticPathsContext, NextPage } from 'next'; -import { GetStaticPropsContext } from 'next'; -import NoDataAvailable from '../../components/common/NoDataAvailable'; -import StoryblokWelcomePage, { - StoryblokWelcomePageProps, -} from '../../components/storyblok/StoryblokWelcomePage'; -import { getStoryblokPageProps } from '../../utils/getStoryblokPageProps'; - -interface Props { - story: ISbStoryData | null; -} - -const Welcome: NextPage<Props> = ({ story }) => { - story = useStoryblokState(story); - - if (!story) { - return <NoDataAvailable />; - } - - return ( - <StoryblokWelcomePage - {...(story.content as StoryblokWelcomePageProps)} - storySlug={story.slug} - /> - ); -}; - -export async function getStaticProps({ locale, preview = false, params }: GetStaticPropsContext) { - const partnerName = params?.partnerName; - const storyblokProps = await getStoryblokPageProps(`welcome/${partnerName}`, locale, preview); - - return { - props: { - ...storyblokProps, - messages: { - ...require(`../../messages/shared/${locale}.json`), - ...require(`../../messages/navigation/${locale}.json`), - ...require(`../../messages/welcome/${locale}.json`), - }, - }, - revalidate: 3600, // revalidate every hour - }; -} - -export async function getStaticPaths({ locales }: GetStaticPathsContext) { - let sbParams: ISbStoriesParams = { - published: true, - starts_with: 'welcome/', - }; - - const storyblokApi = getStoryblokApi(); - let data = await storyblokApi.getAll('cdn/links', sbParams); - - let paths: any = []; - - data.forEach((story: Partial<ISbStoryData>) => { - if (!story.slug) return; - - // get array for slug because of catch all - let splittedSlug = story.slug.split('/'); - - if (locales) { - // create additional languages - for (const locale of locales) { - paths.push({ params: { partnerName: splittedSlug[1] }, locale }); - } - } - }); - - return { - paths, - fallback: false, - }; -} - -export default Welcome; diff --git a/store/store.ts b/store/store.ts index ff8984c0f..9f70733b2 100644 --- a/store/store.ts +++ b/store/store.ts @@ -7,7 +7,7 @@ import partnerAdminReducer from './partnerAdminSlice'; import partnersReducer from './partnersSlice'; import userReducer from './userSlice'; -const makeStore = () => +export const makeStore = () => configureStore({ reducer: { [api.reducerPath]: api.reducer, diff --git a/store/storeProvider.tsx b/store/storeProvider.tsx new file mode 100644 index 000000000..d9205fcdb --- /dev/null +++ b/store/storeProvider.tsx @@ -0,0 +1,10 @@ +'use client'; + +import { Provider } from 'react-redux'; +import { makeStore } from './store'; + +export default function StoreProvider({ children }: { children: React.ReactNode }) { + const store = makeStore(); + + return <Provider store={store}>{children}</Provider>; +} diff --git a/styles/common.ts b/styles/common.ts index 5d748c65b..775e7fb67 100644 --- a/styles/common.ts +++ b/styles/common.ts @@ -1,3 +1,6 @@ +// THIS COLOUR IS USED FOR PWA META TAG CONFIGURATION IN A SERVER COMPONENT +export const COLOUR_PRIMARY_MAIN = '#F3D6D8'; + export const rowStyle = { display: 'flex', flexDirection: 'row', diff --git a/styles/globals.css b/styles/globals.css index 58439b056..e85ec005f 100644 --- a/styles/globals.css +++ b/styles/globals.css @@ -1,3 +1,5 @@ +@import url('https://fonts.googleapis.com/css2?family=Montserrat:ital,wght@0,300;0,400;0,500;1,300;1,400;1,500&family=Open+Sans:ital,wght@0,300;0,400;0,600;1,300;1,400;1,600&display=swap'); + html, body { padding: 0; diff --git a/styles/theme.ts b/styles/theme.ts index 7ed359ecd..46e56b945 100644 --- a/styles/theme.ts +++ b/styles/theme.ts @@ -1,4 +1,7 @@ +'use client'; + import { createTheme, lighten, responsiveFontSizes } from '@mui/material/styles'; +import { COLOUR_PRIMARY_MAIN } from './common'; // If you want to declare custom colours that aren't officially in the palette, add them here declare module '@mui/material/styles' { @@ -12,11 +15,12 @@ declare module '@mui/material/styles' { bloomGradient?: string; } } + // Create a theme instance. let theme = createTheme({ palette: { primary: { - main: '#F3D6D8', + main: COLOUR_PRIMARY_MAIN, light: '#F7E2E4', dark: '#EA0050', }, diff --git a/yarn.lock b/yarn.lock index 3f7a10014..cee0f6b9a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2017,6 +2017,13 @@ clsx "^2.1.0" prop-types "^15.8.1" +"@mui/material-nextjs@^5.16.4": + version "5.16.4" + resolved "https://registry.yarnpkg.com/@mui/material-nextjs/-/material-nextjs-5.16.4.tgz#4070fdd1f6c991529f2bef9d4520cf134aa23b22" + integrity sha512-m2fY/bdfvpUXkjv2k5cwqd42FJZ8QRuZ1MoWt6RW480yIVi4ZRFpccBnJjiC4rXIeslmd/jizHi65Hbz/L/AKQ== + dependencies: + "@babel/runtime" "^7.23.9" + "@mui/material@^5.16.1": version "5.16.1" resolved "https://registry.yarnpkg.com/@mui/material/-/material-5.16.1.tgz#6fcef9b5709df5864cf0b0bc0ea7b453a9d9e420" @@ -2487,6 +2494,11 @@ dependencies: "@types/node" "*" +"@types/cookie@^0.6.0": + version "0.6.0" + resolved "https://registry.yarnpkg.com/@types/cookie/-/cookie-0.6.0.tgz#eac397f28bf1d6ae0ae081363eca2f425bedf0d5" + integrity sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA== + "@types/estree@0.0.39": version "0.0.39" resolved "https://registry.yarnpkg.com/@types/estree/-/estree-0.0.39.tgz#e177e699ee1b8c22d23174caaa7422644389509f" @@ -2514,6 +2526,14 @@ dependencies: "@types/node" "*" +"@types/hoist-non-react-statics@^3.3.5": + version "3.3.5" + resolved "https://registry.yarnpkg.com/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.5.tgz#dab7867ef789d87e2b4b0003c9d65c49cc44a494" + integrity sha512-SbcrWzkKBw2cdwRTwQAswfpB9g9LJWfjtUeW/jvNwbhC8cpmmNYVePa+ncbUe0rGTQ7G3Ff6mYUN2VMfLVr+Sg== + dependencies: + "@types/react" "*" + hoist-non-react-statics "^3.3.0" + "@types/istanbul-lib-coverage@*", "@types/istanbul-lib-coverage@^2.0.0", "@types/istanbul-lib-coverage@^2.0.1": version "2.0.6" resolved "https://registry.yarnpkg.com/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz#7739c232a1fee9b4d3ce8985f314c0c6d33549d7" @@ -3629,6 +3649,11 @@ convert-source-map@^2.0.0: resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-2.0.0.tgz#4b560f649fc4e918dd0ab75cf4961e8bc882d82a" integrity sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg== +cookie@^0.6.0: + version "0.6.0" + resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.6.0.tgz#2798b04b071b0ecbff0dbb62a505a8efa4e19051" + integrity sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw== + core-js-compat@^3.31.0, core-js-compat@^3.36.1: version "3.37.1" resolved "https://registry.yarnpkg.com/core-js-compat/-/core-js-compat-3.37.1.tgz#c844310c7852f4bdf49b8d339730b97e17ff09ee" @@ -5147,7 +5172,7 @@ hasown@^2.0.0, hasown@^2.0.1, hasown@^2.0.2: dependencies: function-bind "^1.1.2" -hoist-non-react-statics@^3.3.1: +hoist-non-react-statics@^3.3.0, hoist-non-react-statics@^3.3.1, hoist-non-react-statics@^3.3.2: version "3.3.2" resolved "https://registry.yarnpkg.com/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz#ece0acaf71d62c2969c2ec59feff42a4b1a85b45" integrity sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw== @@ -7489,6 +7514,15 @@ react-cookie-consent@^8.0.1: dependencies: js-cookie "^2.2.1" +react-cookie@^7.2.0: + version "7.2.0" + resolved "https://registry.yarnpkg.com/react-cookie/-/react-cookie-7.2.0.tgz#5770cd8d6b3c60c5d34ec4b7926f90281aee22ae" + integrity sha512-mqhPERUyfOljq5yJ4woDFI33bjEtigsl8JDJdPPeNhr0eSVZmBc/2Vdf8mFxOUktQxhxTR1T+uF0/FRTZyBEgw== + dependencies: + "@types/hoist-non-react-statics" "^3.3.5" + hoist-non-react-statics "^3.3.2" + universal-cookie "^7.0.0" + react-dom@^18.3.1: version "18.3.1" resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-18.3.1.tgz#c2265d79511b57d479b3dd3fdfa51536494c5cb4" @@ -8795,6 +8829,14 @@ unique-string@^2.0.0: dependencies: crypto-random-string "^2.0.0" +universal-cookie@^7.0.0: + version "7.2.0" + resolved "https://registry.yarnpkg.com/universal-cookie/-/universal-cookie-7.2.0.tgz#1f3fa9c575d863ac41b4e42272d240ae2d32c047" + integrity sha512-PvcyflJAYACJKr28HABxkGemML5vafHmiL4ICe3e+BEKXRMt0GaFLZhAwgv637kFFnnfiSJ8e6jknrKkMrU+PQ== + dependencies: + "@types/cookie" "^0.6.0" + cookie "^0.6.0" + universalify@^0.1.0: version "0.1.2" resolved "https://registry.yarnpkg.com/universalify/-/universalify-0.1.2.tgz#b646f69be3942dabcecc9d6639c80dc105efaa66"