diff --git a/src/app/components/Account/AccountContainer/index.styles.tsx b/src/app/components/Account/AccountContainer/index.styles.tsx new file mode 100644 index 00000000000..ed4181b002b --- /dev/null +++ b/src/app/components/Account/AccountContainer/index.styles.tsx @@ -0,0 +1,43 @@ +import { Theme, css } from '@emotion/react'; + +export default { + actionLinkWrapper: ({ mq }) => + css({ + display: 'flex', + flexDirection: 'column', + flex: '1 1 auto', + alignItems: 'center', + gap: '1rem', + [mq.GROUP_2_MIN_WIDTH]: { + flexDirection: 'row', + }, + }), + callToActionLink: () => + css({ + padding: '1rem', + }), + + orText: ({ palette }: Theme) => + css({ + color: palette.WHITE, + }), + + signInLnk: ({ palette }: Theme) => + css({ + color: palette.WHITE, + backgroundColor: '#0071F1', + '&:hover, &:focus': { + backgroundColor: '#0051AD', + color: palette.WHITE, + }, + }), + registerLink: ({ palette }: Theme) => + css({ + color: palette.BLACK, + backgroundColor: palette.WHITE, + '&:hover, &:focus': { + backgroundColor: '#F6F6F6', + color: palette.BLACK, + }, + }), +}; diff --git a/src/app/components/Account/AccountContainer/index.tsx b/src/app/components/Account/AccountContainer/index.tsx new file mode 100644 index 00000000000..840c6f3ae58 --- /dev/null +++ b/src/app/components/Account/AccountContainer/index.tsx @@ -0,0 +1,55 @@ +import { use } from 'react'; +import Paragraph from '#app/components/Paragraph'; +import { AccountContext } from '../AccountContext'; +import PromotionalBanner from '../../PromotionalBanner'; +import CallToActionLink from '../../CallToActionLink'; +import styles from './index.styles'; + +export default function AccountContainer() { + const { isSignInAvailable, signInUrl, registerUrl } = use(AccountContext); + + if (!isSignInAvailable || !signInUrl || !registerUrl) { + return null; + } + + return ( +
+ +
+ + + + Sign in + + + + + + + Or + + + + + + Register + + + +
+
+
+ ); +} diff --git a/src/app/components/Account/AccountContext.tsx b/src/app/components/Account/AccountContext.tsx new file mode 100644 index 00000000000..978882f4682 --- /dev/null +++ b/src/app/components/Account/AccountContext.tsx @@ -0,0 +1,66 @@ +import React, { createContext, use, useEffect, useMemo, useState } from 'react'; +import { ServiceContext } from '#app/contexts/ServiceContext'; +import { isSignedIn } from './idcta/isSignedIn'; +import appendCtaQueryParams from './idcta/appendCtaQueryParams'; + +export type AccountContextProps = { + isSignInAvailable: boolean; + accountUrl: string; + signInUrl: string; + registerUrl: string; + isSignedIn: boolean; +}; + +export const AccountContext = createContext( + {} as AccountContextProps, +); + +export const AccountProvider = ({ + children, + initialConfig = null, +}: { + children: React.ReactNode; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + initialConfig: any; +}) => { + const { locale } = use(ServiceContext); + + const [ptrt, setPtrt] = useState(undefined); + + useEffect(() => { + setPtrt(window.location.href); + }, []); + + const signInAvailability = initialConfig.availability.signin === 'GREEN'; + const unavailableUrl = initialConfig?.unavailable_url; + + const signInUrl = signInAvailability + ? appendCtaQueryParams(initialConfig?.signin_url, { ptrt, lang: locale }) + : unavailableUrl; + + const registerUrl = signInAvailability + ? appendCtaQueryParams(initialConfig?.register_url, { ptrt, lang: locale }) + : unavailableUrl; + + const accountUrl = + initialConfig?.['foryou-flagpole'] === 'GREEN' + ? initialConfig.foryou_url + : unavailableUrl; + + const isUserSignedIn = signInAvailability ? isSignedIn() : false; + + const value = useMemo( + () => ({ + isSignInAvailable: signInAvailability, + signInUrl, + registerUrl, + accountUrl, + isSignedIn: isUserSignedIn, + }), + [accountUrl, isUserSignedIn, registerUrl, signInAvailability, signInUrl], + ); + + return ( + {children} + ); +}; diff --git a/src/app/components/Account/HeaderAccount/index.styles.tsx b/src/app/components/Account/HeaderAccount/index.styles.tsx new file mode 100644 index 00000000000..0362628c0c5 --- /dev/null +++ b/src/app/components/Account/HeaderAccount/index.styles.tsx @@ -0,0 +1,33 @@ +import { Theme, css } from '@emotion/react'; +import pixelsToRem from '#app/utilities/pixelsToRem'; + +export default { + icon: ({ palette }: Theme) => + css({ + height: `${pixelsToRem(24)}rem`, + width: `${pixelsToRem(24)}rem`, + color: palette.WHITE, + }), + + linkWrapper: ({ palette }) => + css({ + display: 'flex', + gap: `${pixelsToRem(8)}rem`, + justifyContent: 'center', + alignItems: 'center', + textDecoration: 'none', + '&:hover, &:focus': { + textDecoration: 'underline', + textDecorationColor: palette.WHITE, + }, + }), + + linkText: ({ palette, mq }: Theme) => + css({ + color: palette.WHITE, + + [mq.GROUP_1_MAX_WIDTH]: { + display: 'none', + }, + }), +}; diff --git a/src/app/components/Account/HeaderAccount/index.tsx b/src/app/components/Account/HeaderAccount/index.tsx new file mode 100644 index 00000000000..f3a46704181 --- /dev/null +++ b/src/app/components/Account/HeaderAccount/index.tsx @@ -0,0 +1,46 @@ +import { use } from 'react'; +import Text from '#app/components/Text'; +import useHydrationDetection from '#app/hooks/useHydrationDetection'; +import { AccountContext } from '../AccountContext'; +import styles from './index.styles'; + +const AccountIcon = () => { + return ( + + ); +}; + +const HeaderAccount = () => { + const isHydrated = useHydrationDetection(); + + const { isSignedIn, signInUrl, accountUrl, isSignInAvailable } = + use(AccountContext); + + if (!isSignInAvailable || !isHydrated) { + return null; + } + + return ( +
+ + + {isSignedIn ? 'For you' : 'Sign In'}{' '} + + + +
+ ); +}; + +export default HeaderAccount; diff --git a/src/app/components/Account/hooks/useIdctaConfig.ts b/src/app/components/Account/hooks/useIdctaConfig.ts new file mode 100644 index 00000000000..34cf156a334 --- /dev/null +++ b/src/app/components/Account/hooks/useIdctaConfig.ts @@ -0,0 +1,86 @@ +import { useEffect, useState } from 'react'; +import appendCtaQueryParams from '../idcta/appendCtaQueryParams'; +import { getIdctaConfigUrl } from '../idcta/getIdctaBaseUrl'; + +type UseIdctaConfigArgs = { + ptrt?: string; + userOrigin?: string; + sequenceId?: string; +}; + +type UseIdctaConfigState = { + signInUrl: string | null; + registerUrl: string | null; + isSignInAvailable: boolean; + availability: { signin: string; refresh: string }; +}; + +const initialState: UseIdctaConfigState = { + signInUrl: null, + registerUrl: null, + isSignInAvailable: false, + availability: { signin: '', refresh: '' }, +}; + +// Can be used if configuration needs to be fetched on the client side. +// Currently not used in this implementation. +export default function useIdctaConfig({ + ptrt, + userOrigin = 'simorgh', +}: UseIdctaConfigArgs = {}) { + const [state, setState] = useState(initialState); + const [error, setError] = useState(null); + + const idctaConfigUrl = getIdctaConfigUrl(); + + useEffect(() => { + if (!idctaConfigUrl) return; + + const controller = new AbortController(); + + const getIdctaConfig = async () => { + try { + setError(null); + + const response = await fetch(idctaConfigUrl, { + cache: 'no-store', + credentials: 'include', + signal: controller.signal, + }); + if (!response.ok) { + throw new Error( + `Failed to fetch IDCTA config: ${response.status} ${response.statusText}`, + ); + } + + const body = await response.json(); + + const signInAvailable = body?.availability?.signin === 'GREEN'; + const unavailable = body?.unavailable_url; + + const signInHref = signInAvailable ? body?.signin_url : unavailable; + const registerHref = signInAvailable ? body?.register_url : unavailable; + + setState({ + signInUrl: signInHref + ? appendCtaQueryParams(signInHref, { ptrt }) + : null, + registerUrl: registerHref + ? appendCtaQueryParams(registerHref, { ptrt }) + : null, + isSignInAvailable: signInAvailable, + availability: body?.availability ?? { signin: '', refresh: '' }, + }); + } catch (e) { + if ((e as Error)?.name === 'AbortError') return; + setError(e as Error); + } + }; + + getIdctaConfig(); + // eslint-disable-next-line consistent-return + return () => controller.abort(); + }, [idctaConfigUrl, ptrt, userOrigin]); + + return { ...state, error }; +} diff --git a/src/app/components/Account/idcta/appendCtaQueryParams.ts b/src/app/components/Account/idcta/appendCtaQueryParams.ts new file mode 100644 index 00000000000..a2469391d4b --- /dev/null +++ b/src/app/components/Account/idcta/appendCtaQueryParams.ts @@ -0,0 +1,13 @@ +type Params = { + ptrt?: string; + lang?: string; +}; + +export default (url: string, { ptrt, lang }: Params = {}): string => { + const ctaUrl = new URL(url); + + if (ptrt) ctaUrl.searchParams.set('ptrt', ptrt); + if (lang) ctaUrl.searchParams.set('lang', lang); + + return ctaUrl.toString(); +}; diff --git a/src/app/components/Account/idcta/fetchIdctaConfig.ts b/src/app/components/Account/idcta/fetchIdctaConfig.ts new file mode 100644 index 00000000000..0788969a704 --- /dev/null +++ b/src/app/components/Account/idcta/fetchIdctaConfig.ts @@ -0,0 +1,36 @@ +import { getIdctaConfigUrl } from './getIdctaBaseUrl'; + +export type IdctaConfigFetchResult = { + // TODO: Extra fields temp for testing + ok: boolean; + status: number; + statusText: string; + body: unknown | null; +}; + +export default async function fetchIdctaConfig(): Promise { + const idctaConfigUrl = getIdctaConfigUrl(); + + try { + const response = await fetch(idctaConfigUrl, { + cache: 'no-store', + }); + + const body = await response.json(); + + return { + ok: response.ok, + status: response.status, + statusText: response.statusText, + body, + }; + } catch (error) { + return { + ok: false, + status: 500, + statusText: + error instanceof Error ? error.message : 'Failed to fetch IDCTA config', + body: null, + }; + } +} diff --git a/src/app/components/Account/idcta/getIdctaBaseUrl.ts b/src/app/components/Account/idcta/getIdctaBaseUrl.ts new file mode 100644 index 00000000000..ac81c21a67b --- /dev/null +++ b/src/app/components/Account/idcta/getIdctaBaseUrl.ts @@ -0,0 +1,10 @@ +import isLive from '../../../lib/utilities/isLive'; + +export const getIdctaBaseUrl = (): string => { + return isLive() + ? 'https://idcta.api.bbc.com/idcta' + : 'https://idcta.test.api.bbc.com/idcta'; +}; + +export const getIdctaConfigUrl = (): string => `${getIdctaBaseUrl()}/config`; +export const getIdctaInitUrl = (): string => `${getIdctaBaseUrl()}/init`; diff --git a/src/app/components/Account/idcta/isSignedIn.ts b/src/app/components/Account/idcta/isSignedIn.ts new file mode 100644 index 00000000000..16a7d6d44d2 --- /dev/null +++ b/src/app/components/Account/idcta/isSignedIn.ts @@ -0,0 +1,9 @@ +import Cookie from 'js-cookie'; + +export const MARKER_COOKIE_NAME = 'ckns_id'; + +// TODO: Use cookie name returned by IDCTA +export const isSignedIn = () => { + if (typeof window === 'undefined') return false; + return Cookie.get(MARKER_COOKIE_NAME) !== undefined; +}; diff --git a/src/app/components/PWAPromotionalBanner/index.tsx b/src/app/components/PWAPromotionalBanner/index.tsx index 7e4c980886d..1716ffa34ac 100644 --- a/src/app/components/PWAPromotionalBanner/index.tsx +++ b/src/app/components/PWAPromotionalBanner/index.tsx @@ -143,7 +143,14 @@ const PWAPromotionalBannerTreatment = ({ [onPrimaryClickTrack, promptInstall], ); - if (!isVisible || !isInstallable || !promotionalBanner) { + if ( + !isVisible || + !isInstallable || + !promotionalBanner || + // TODO: TEMP - update PromotionalBanner if needed + !promotionalBanner?.primaryButton || + !promotionalBanner?.secondaryButton + ) { return null; } @@ -154,12 +161,12 @@ const PWAPromotionalBannerTreatment = ({ description={promotionalBanner.description} orText={promotionalBanner.orText} primaryButton={{ - text: promotionalBanner.primaryButton.text, - longText: promotionalBanner.primaryButton.longText, + text: promotionalBanner?.primaryButton.text, + longText: promotionalBanner?.primaryButton?.longText, }} onPrimaryClick={handlePrimaryClick} secondaryButton={{ - text: promotionalBanner.secondaryButton.text, + text: promotionalBanner?.secondaryButton.text, }} onSecondaryClick={handleSecondaryClick} isDismissible diff --git a/src/app/components/PromotionalBanner/index.tsx b/src/app/components/PromotionalBanner/index.tsx index 134f5fd7a4d..9b89b5f3f7d 100644 --- a/src/app/components/PromotionalBanner/index.tsx +++ b/src/app/components/PromotionalBanner/index.tsx @@ -1,3 +1,4 @@ +import { PropsWithChildren } from 'react'; import type { PromotionalBannerProps } from './index.types'; import styles from './index.styles'; import { Close } from '../icons'; @@ -18,8 +19,9 @@ const PromotionalBanner = ({ onSecondaryClick, bannerLabel, closeLabel, + children, id = 'promotional-banner', -}: PromotionalBannerProps) => { +}: PropsWithChildren) => { return (