dotcom-rendering/src/components/SignInGateSelector.importable.tsx (531 lines of code) (raw):

import { getCookie, isUndefined } from '@guardian/libs'; import { useEffect, useState } from 'react'; import { hasCmpConsentForBrowserId, shouldHideSupportMessaging, } from '../lib/contributions'; import { getDailyArticleCount, getToday } from '../lib/dailyArticleCount'; import type { EditionId } from '../lib/edition'; import { getLocaleCode } from '../lib/getCountryCode'; import { isUserLoggedIn } from '../lib/identity'; import { parseCheckoutCompleteCookieData } from '../lib/parser/parseCheckoutOutCookieData'; import { constructQuery } from '../lib/querystring'; import { useAB } from '../lib/useAB'; import { useAuthStatus } from '../lib/useAuthStatus'; import { useCountryCode } from '../lib/useCountryCode'; import { useOnce } from '../lib/useOnce'; import { usePageViewId } from '../lib/usePageViewId'; import { useSignInGateSelector } from '../lib/useSignInGateSelector'; import type { Switches } from '../types/config'; import type { RenderingTarget } from '../types/renderingTarget'; import type { TagType } from '../types/tag'; import { useConfig } from './ConfigContext'; import type { ComponentEventParams } from './SignInGate/componentEventTracking'; import { submitComponentEventTracking, submitViewEventTracking, withComponentId, } from './SignInGate/componentEventTracking'; import { incrementUserDismissedGateCount, retrieveDismissedCount, setUserDismissedGate, } from './SignInGate/dismissGate'; import { pageIdIsAllowedForGating } from './SignInGate/displayRules'; import { SignInGateAuxia } from './SignInGate/gateDesigns/SignInGateAuxia'; import { signInGateTestIdToComponentId } from './SignInGate/signInGateMappings'; import type { AuxiaAPIResponseDataUserTreatment, AuxiaGateDisplayData, AuxiaGateReaderPersonalData, AuxiaInteractionActionName, AuxiaInteractionInteractionType, AuxiaProxyGetTreatmentsPayload, AuxiaProxyGetTreatmentsResponse, AuxiaProxyLogTreatmentInteractionPayload, CheckoutCompleteCookieData, CurrentSignInGateABTest, SignInGateComponent, } from './SignInGate/types'; // ------------------------------------------------------------------------------------------ // Default (pre Auxia Integration Experiment) types, SignInGateSelector and ShowSignInGate // // ------------------------------------------------------------------------------------------ type Props = { contentType: string; sectionId?: string; tags: TagType[]; isPaidContent: boolean; isPreview: boolean; host?: string; pageId: string; idUrl?: string; switches: Switches; contributionsServiceUrl: string; editionId: EditionId; }; type PropsDefault = { contentType: string; sectionId?: string; tags: TagType[]; isPaidContent: boolean; isPreview: boolean; host?: string; pageId: string; idUrl?: string; switches: Switches; }; // interface for the component which shows the sign in gate interface ShowSignInGateProps { setShowGate: React.Dispatch<React.SetStateAction<boolean>>; abTest: CurrentSignInGateABTest; componentId: string; signInUrl: string; registerUrl: string; gateVariant: SignInGateComponent; host: string; checkoutCompleteCookieData?: CheckoutCompleteCookieData; personaliseSignInGateAfterCheckoutSwitch?: boolean; } const dismissGate = ( setShowGate: React.Dispatch<React.SetStateAction<boolean>>, currentAbTestValue: CurrentSignInGateABTest, ) => { setShowGate(false); setUserDismissedGate(currentAbTestValue.variant, currentAbTestValue.name); incrementUserDismissedGateCount( currentAbTestValue.variant, currentAbTestValue.name, ); }; // function to generate the profile.theguardian.com url with tracking params // and the return url (link to current article page) const generateGatewayUrl = ( tab: 'register' | 'signin', { pageId, pageViewId, idUrl, host, currentTest, componentId, }: { pageId: string; pageViewId: string; idUrl: string; host: string; currentTest: CurrentSignInGateABTest; componentId?: string; }, ) => { // url of the article, return user here after sign in/registration const returnUrl = `${host}/${pageId}`; // set the component event params to be included in the query const queryParams: ComponentEventParams = { componentType: 'signingate', componentId, abTestName: currentTest.name, abTestVariant: currentTest.variant, browserId: getCookie({ name: 'bwid', shouldMemoize: true }) ?? undefined, viewId: pageViewId, }; return `${idUrl}/${tab}?returnUrl=${returnUrl}&componentEventParams=${encodeURIComponent( constructQuery(queryParams), )}`; }; // component which shows the sign in gate // fires a VIEW ophan component event // and show the gate component if it exists const ShowSignInGate = ({ abTest, componentId, setShowGate, signInUrl, registerUrl, gateVariant, host, checkoutCompleteCookieData, personaliseSignInGateAfterCheckoutSwitch, }: ShowSignInGateProps) => { const { renderingTarget } = useConfig(); // use effect hook to fire view event tracking only on initial render useEffect(() => { submitViewEventTracking( { component: withComponentId(componentId), abTest, }, renderingTarget, ); }, [abTest, componentId, renderingTarget]); // some sign in gate ab test variants may not need to show a gate // therefore the gate is optional // this is because we want a section of the audience to never see the gate // but still fire a view event if they are eligible to see the gate if (gateVariant.gate) { return gateVariant.gate({ guUrl: host, signInUrl, registerUrl, dismissGate: () => { dismissGate(setShowGate, abTest); }, abTest, ophanComponentId: componentId, checkoutCompleteCookieData, personaliseSignInGateAfterCheckoutSwitch, }); } // return nothing if no gate needs to be shown return <></>; }; const useCheckoutCompleteCookieData = () => { const [data, setData] = useState<CheckoutCompleteCookieData>(); useEffect(() => { const rawCookie = getCookie({ name: 'GU_CO_COMPLETE', shouldMemoize: true, }); if (rawCookie === null) return; const parsedCookieData = parseCheckoutCompleteCookieData(rawCookie); if (parsedCookieData) setData(parsedCookieData); }, []); return data; }; /** * Component with conditional logic which determines if a sign in gate * should be shown on the current page. * * ## Why does this need to be an Island? * * The sign-in gate logic is entirely client-side * * --- * * (No visual story exists) */ const SignInGateSelectorDefault = ({ contentType, sectionId = '', tags, isPaidContent, isPreview, host = 'https://theguardian.com/', pageId, idUrl = 'https://profile.theguardian.com', switches, }: PropsDefault) => { const authStatus = useAuthStatus(); const isSignedIn = authStatus.kind === 'SignedIn'; const [isGateDismissed, setIsGateDismissed] = useState<boolean | undefined>( undefined, ); const [gateVariant, setGateVariant] = useState< SignInGateComponent | undefined >(undefined); const [currentTest, setCurrentTest] = useState< CurrentSignInGateABTest | undefined >(undefined); const [canShowGate, setCanShowGate] = useState(false); const { renderingTarget } = useConfig(); const gateSelector = useSignInGateSelector(); const pageViewId = usePageViewId(renderingTarget); // START: Checkout Complete Personalisation const [personaliseSwitch, setPersonaliseSwitch] = useState(false); const checkoutCompleteCookieData = useCheckoutCompleteCookieData(); const personaliseComponentId = ( currentComponentId: string | undefined, ): string | undefined => { if (!currentComponentId) return undefined; if (!checkoutCompleteCookieData) return currentComponentId; const { userType, product } = checkoutCompleteCookieData; return `${currentComponentId}_personalised_${userType}_${product}`; }; const shouldPersonaliseComponentId = (): boolean => { return personaliseSwitch && !!checkoutCompleteCookieData; }; const { personaliseSignInGateAfterCheckout } = switches; // END: Checkout Complete Personalisation const countryCode = useCountryCode('sign-in-gate-selector'); useOnce(() => { // this hook will fire when the sign in gate is dismissed // which will happen when the showGate state is set to false // this only happens within the dismissGate method if (isGateDismissed) { document.dispatchEvent( new CustomEvent('article:sign-in-gate-dismissed'), ); } }, [isGateDismissed]); useOnce(() => { const [gateSelectorVariant, gateSelectorTest] = gateSelector as [ SignInGateComponent | null, CurrentSignInGateABTest | null, ]; if (gateSelectorVariant && gateSelectorTest) { setGateVariant(gateSelectorVariant); setCurrentTest(gateSelectorTest); } }, [gateSelector]); useEffect(() => { if (personaliseSignInGateAfterCheckout) { setPersonaliseSwitch(personaliseSignInGateAfterCheckout); } else { setPersonaliseSwitch(false); } }, [personaliseSignInGateAfterCheckout]); useEffect(() => { if (gateVariant && currentTest) { void gateVariant .canShow({ isSignedIn: !!isSignedIn, currentTest, contentType, sectionId, tags, isPaidContent, isPreview, currentLocaleCode: countryCode, }) .then(setCanShowGate); } }, [ currentTest, gateVariant, isSignedIn, contentType, sectionId, tags, isPaidContent, isPreview, countryCode, ]); if (!currentTest || !gateVariant || isUndefined(pageViewId)) { return null; } const signInGateComponentId = signInGateTestIdToComponentId[currentTest.id]; const componentId = shouldPersonaliseComponentId() ? personaliseComponentId(signInGateComponentId) : signInGateComponentId; const ctaUrlParams = { pageId, host, pageViewId, idUrl, currentTest, componentId, } satisfies Parameters<typeof generateGatewayUrl>[1]; return ( <> {/* Sign In Gate Display Logic */} {!isGateDismissed && canShowGate && !!componentId && ( <ShowSignInGate abTest={currentTest} componentId={componentId} // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions -- Odd react types, should review setShowGate={(show) => setIsGateDismissed(!show)} signInUrl={generateGatewayUrl('signin', ctaUrlParams)} registerUrl={generateGatewayUrl('register', ctaUrlParams)} gateVariant={gateVariant} host={host} checkoutCompleteCookieData={checkoutCompleteCookieData} personaliseSignInGateAfterCheckoutSwitch={personaliseSwitch} /> )} </> ); }; // ------------------------------- // Auxia Integration Experiment // // ------------------------------- export const SignInGateSelector = ({ contentType, sectionId = '', tags, isPaidContent, isPreview, host = 'https://theguardian.com/', pageId, // pageId is the path without starting slash idUrl = 'https://profile.theguardian.com', switches, contributionsServiceUrl, editionId, }: Props) => { const abTestAPI = useAB()?.api; const userIsInAuxiaExperiment = !!abTestAPI?.isUserInVariant( 'AuxiaSignInGate', 'auxia-signin-gate', ); if (!pageIdIsAllowedForGating(pageId)) { return <></>; } if (!userIsInAuxiaExperiment) { return ( <SignInGateSelectorDefault contentType={contentType} sectionId={sectionId} tags={tags} isPaidContent={isPaidContent} isPreview={isPreview} host={host} pageId={pageId} idUrl={idUrl} switches={switches} /> ); } else { return ( <SignInGateSelectorAuxia host={host} pageId={pageId} idUrl={idUrl} contributionsServiceUrl={contributionsServiceUrl} editionId={editionId} isPreview={isPreview} isPaidContent={isPaidContent} contentType={contentType} sectionId={sectionId} tags={tags} /> ); } }; /* Date: January 2025 comment group: auxia-prototype-e55a86ef Author: Pascal We are currently starting to evaluate a new approach for the decision to display the sign gate or not: an external API, that will be returning that decision. The company offering that API is called Auxia and can be found here: https://www.auxia.io At this aim I have move the existing legacy version of the SignInGateSelector to SignInGateSelectorDefault, and I am introducing SignInGateSelectorAuxia, which is the new component that will be using the Auxia API. This work, until further notice is the implementation and evaluation of a prototype. All comments related to this prototype comes under the same comment group: auxia-prototype-e55a86ef (follow it if one day you want to decommission the entire prototype). PRs for ab test definition: - https://github.com/guardian/frontend/pull/27743 - https://github.com/guardian/frontend/pull/27744 - https://github.com/guardian/dotcom-rendering/pull/13197 */ type PropsAuxia = { host?: string; pageId: string; idUrl: string; contributionsServiceUrl: string; editionId: EditionId; isPreview: boolean; isPaidContent: boolean; contentType: string; sectionId: string; tags: TagType[]; }; interface ShowSignInGateAuxiaProps { host: string; signInUrl: string; setShowGate: React.Dispatch<React.SetStateAction<boolean>>; abTest: CurrentSignInGateABTest; userTreatment: AuxiaAPIResponseDataUserTreatment; contributionsServiceUrl: string; browserId: string | undefined; treatmentId: string; renderingTarget: RenderingTarget; logTreatmentInteractionCall: ( interactionType: AuxiaInteractionInteractionType, actionName: AuxiaInteractionActionName, ) => Promise<void>; } const decideIsSupporter = (): boolean => { // nb: We will not be calling the Auxia API if the user is signed in, so we can set isSignedIn to false. const isSignedIn = false; const isSupporter = shouldHideSupportMessaging(isSignedIn); if (isSupporter === 'Pending') { return true; } return isSupporter; }; const decideDailyArticleCount = (): number => { const value = getDailyArticleCount(); if (value === undefined) { return 0; } const today = getToday(); // number of days since unix epoch for today date for (const daily of value) { if (daily.day === today) { return daily.count; } } return 0; }; const decideAuxiaProxyReaderPersonalData = async (): Promise<AuxiaGateReaderPersonalData> => { const browserId = getCookie({ name: 'bwid', shouldMemoize: true }) ?? undefined; const dailyArticleCount = decideDailyArticleCount(); const hasConsent = await hasCmpConsentForBrowserId(); const isSupporter = decideIsSupporter(); const countryCode = (await getLocaleCode()) ?? ''; // default to empty string const data = { browserId: hasConsent ? browserId : undefined, dailyArticleCount, isSupporter, countryCode, }; return Promise.resolve(data); }; const fetchProxyGetTreatments = async ( contributionsServiceUrl: string, pageId: string, browserId: string | undefined, isSupporter: boolean, dailyArticleCount: number, editionId: EditionId, contentType: string, sectionId: string, tagIds: string[], gateDismissCount: number, countryCode: string, ): Promise<AuxiaProxyGetTreatmentsResponse> => { // pageId example: 'money/2017/mar/10/ministers-to-criminalise-use-of-ticket-tout-harvesting-software' const articleIdentifier = `www.theguardian.com/${pageId}`; // articleIdentifier example: 'www.theguardian.com/money/2017/mar/10/ministers-to-criminalise-use-of-ticket-tout-harvesting-software' const url = `${contributionsServiceUrl}/auxia/get-treatments`; const headers = { 'Content-Type': 'application/json', }; const payload: AuxiaProxyGetTreatmentsPayload = { browserId, isSupporter, dailyArticleCount, articleIdentifier, editionId, contentType, sectionId, tagIds, gateDismissCount, countryCode, }; const params = { method: 'POST', headers, body: JSON.stringify(payload), }; const response_raw = await fetch(url, params); const response = (await response_raw.json()) as AuxiaProxyGetTreatmentsResponse; return Promise.resolve(response); }; const buildAuxiaGateDisplayData = async ( contributionsServiceUrl: string, pageId: string, editionId: EditionId, contentType: string, sectionId: string, tags: TagType[], gateDismissCount: number, ): Promise<AuxiaGateDisplayData | undefined> => { const readerPersonalData = await decideAuxiaProxyReaderPersonalData(); const tagIds = tags.map((tag) => tag.id); const response = await fetchProxyGetTreatments( contributionsServiceUrl, pageId, readerPersonalData.browserId, readerPersonalData.isSupporter, readerPersonalData.dailyArticleCount, editionId, contentType, sectionId, tagIds, gateDismissCount, readerPersonalData.countryCode, ); if (response.status && response.data) { const answer = { browserId: readerPersonalData.browserId, auxiaData: response.data, }; return Promise.resolve(answer); } return Promise.resolve(undefined); }; const auxiaLogTreatmentInteraction = async ( contributionsServiceUrl: string, userTreatment: AuxiaAPIResponseDataUserTreatment, interactionType: AuxiaInteractionInteractionType, actionName: AuxiaInteractionActionName, browserId: string | undefined, ): Promise<void> => { const url = `${contributionsServiceUrl}/auxia/log-treatment-interaction`; const headers = { 'Content-Type': 'application/json', }; const microTime = Date.now() * 1000; const payload: AuxiaProxyLogTreatmentInteractionPayload = { browserId, treatmentTrackingId: userTreatment.treatmentTrackingId, treatmentId: userTreatment.treatmentId, surface: userTreatment.surface, interactionType, interactionTimeMicros: microTime, actionName, }; const params = { method: 'POST', headers, body: JSON.stringify(payload), }; await fetch(url, params); }; const buildAbTestTrackingAuxiaVariant = ( treatmentId: string, ): { name: string; variant: string; id: string; } => { return { name: 'AuxiaSignInGate', variant: treatmentId, id: treatmentId, }; }; const SignInGateSelectorAuxia = ({ host = 'https://theguardian.com/', pageId, idUrl, contributionsServiceUrl, editionId, isPreview, isPaidContent, contentType, sectionId, tags, }: PropsAuxia) => { /* comment group: auxia-prototype-e55a86ef This function if the Auxia prototype for the SignInGateSelector component. */ const [isGateDismissed, setIsGateDismissed] = useState<boolean | undefined>( undefined, ); const [auxiaGateDisplayData, setAuxiaGateDisplayData] = useState< AuxiaGateDisplayData | undefined >(undefined); // We are using CurrentSignInGateABTest, with the details of the Auxia experiment, // to allow Ophan tracking const abTest: CurrentSignInGateABTest = { name: 'AuxiaSignInGate', // value of dataLinkNames variant: 'auxia-signin-gate', // variant id id: 'AuxiaSignInGate', // test id }; const { renderingTarget } = useConfig(); const pageViewId = usePageViewId(renderingTarget); useOnce(() => { // this hook will fire when the sign in gate is dismissed // which will happen when the showGate state is set to false // this only happens within the dismissGate method if (isGateDismissed) { document.dispatchEvent( new CustomEvent('article:sign-in-gate-dismissed'), ); } }, [isGateDismissed]); useOnce(() => { void (async () => { // Only make a request to Auxia if user is signed out. This means signed-in users will never receive an Auxia treatment, and therefore never see a sign-in gate const isSignedIn = await isUserLoggedIn(); // Although the component is returning null if we are in preview or it's a paid content // We need to guard against the API possibly being called before the component returns. // That is because it would count as a content delivery for them, above all if they return a treatment // without the subsequent Log Treatment notification, which would cause confusion. if (!isSignedIn && !isPreview && !isPaidContent) { const data = await buildAuxiaGateDisplayData( contributionsServiceUrl, pageId, editionId, contentType, sectionId, tags, retrieveDismissedCount(abTest.variant, abTest.name), ); if (data !== undefined) { setAuxiaGateDisplayData(data); const treatmentId = data.auxiaData.userTreatment?.treatmentId; if (treatmentId) { // Record the fact that Auxia has returned a treatment. This is not a VIEW event, so we use the RETURN action here void submitComponentEventTracking( { component: { componentType: 'SIGN_IN_GATE', id: treatmentId, }, action: 'RETURN', abTest: buildAbTestTrackingAuxiaVariant( treatmentId, ), }, renderingTarget, ); } } } })().catch((error) => { console.error('Error fetching Auxia display data:', error); }); }, [abTest, isPaidContent, isPreview]); // We are not showing the gate if we are in preview, it's a paid contents // or the user is signed in or if for some reasons we could not determine the // pageViewId // According to the reacts rules we can only put this check after all the hooks. if (isPreview || isPaidContent || isUndefined(pageViewId)) { return null; } const ctaUrlParams = { pageId, host, pageViewId, idUrl, currentTest: abTest, componentId: abTest.id, } satisfies Parameters<typeof generateGatewayUrl>[1]; const signInUrl = generateGatewayUrl('signin', ctaUrlParams); return ( <> {!isGateDismissed && auxiaGateDisplayData?.auxiaData.userTreatment !== undefined && ( <ShowSignInGateAuxia host={host} signInUrl={signInUrl} // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions -- Odd react types, should review setShowGate={(show) => setIsGateDismissed(!show)} abTest={buildAbTestTrackingAuxiaVariant( auxiaGateDisplayData.auxiaData.userTreatment .treatmentId, )} userTreatment={ auxiaGateDisplayData.auxiaData.userTreatment } contributionsServiceUrl={contributionsServiceUrl} browserId={auxiaGateDisplayData.browserId} treatmentId={ auxiaGateDisplayData.auxiaData.userTreatment .treatmentId } renderingTarget={renderingTarget} logTreatmentInteractionCall={async ( interactionType: AuxiaInteractionInteractionType, actionName: AuxiaInteractionActionName, ) => { await auxiaLogTreatmentInteraction( contributionsServiceUrl, auxiaGateDisplayData.auxiaData.userTreatment!, interactionType, actionName, auxiaGateDisplayData.browserId, ); }} /> )} </> ); }; const ShowSignInGateAuxia = ({ host, signInUrl, setShowGate, abTest, userTreatment, contributionsServiceUrl, browserId, treatmentId, renderingTarget, logTreatmentInteractionCall, }: ShowSignInGateAuxiaProps) => { const componentId = 'main_variant_5'; const checkoutCompleteCookieData = undefined; const personaliseSignInGateAfterCheckoutSwitch = undefined; useOnce(() => { void auxiaLogTreatmentInteraction( contributionsServiceUrl, userTreatment, 'VIEWED', '', browserId, ).catch((error) => { console.error('Failed to log treatment interaction:', error); }); void submitComponentEventTracking( { component: { componentType: 'SIGN_IN_GATE', id: treatmentId, }, action: 'VIEW', abTest: buildAbTestTrackingAuxiaVariant(treatmentId), }, renderingTarget, ); }, [componentId]); return ( <SignInGateAuxia guUrl={host} signInUrl={signInUrl} dismissGate={() => { setShowGate(false); }} abTest={abTest} ophanComponentId={componentId} checkoutCompleteCookieData={checkoutCompleteCookieData} personaliseSignInGateAfterCheckoutSwitch={ personaliseSignInGateAfterCheckoutSwitch } userTreatment={userTreatment} logTreatmentInteractionCall={logTreatmentInteractionCall} /> ); };