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 (