diff --git a/client/components/mma/MMAPage.tsx b/client/components/mma/MMAPage.tsx index 7f07a2f5f..fe5cc9862 100644 --- a/client/components/mma/MMAPage.tsx +++ b/client/components/mma/MMAPage.tsx @@ -52,6 +52,12 @@ const AccountOverview = lazyWithRetry(() => ).then(({ AccountOverview }) => ({ default: AccountOverview })), ); +const ExtraAccounts = lazyWithRetry(() => + import( + /* webpackChunkName: "ExtraAccounts" */ './extraAccounts/ExtraAccounts' + ).then(({ ExtraAccounts }) => ({ default: ExtraAccounts })), +); + const Billing = lazyWithRetry(() => import(/* webpackChunkName: "Billing" */ './billing/Billing').then( ({ Billing }) => ({ default: Billing }), @@ -588,6 +594,11 @@ const MMARouter = () => { element={} /> + } + /> + } /> {Object.values(PRODUCT_TYPES).map( (productType: ProductType) => ( diff --git a/client/components/mma/MMAPageSkeleton.tsx b/client/components/mma/MMAPageSkeleton.tsx index b58df7849..bb461f5b1 100644 --- a/client/components/mma/MMAPageSkeleton.tsx +++ b/client/components/mma/MMAPageSkeleton.tsx @@ -92,6 +92,11 @@ const MMALocationObjectArr: LocationObject[] = [ path: '/', selectedNavItem: NAV_LINKS.accountOverview, }, + { + title: 'Extra accounts', + path: '/extra-accounts', + selectedNavItem: NAV_LINKS.extraAccounts, + }, { title: 'Billing', path: '/billing', diff --git a/client/components/mma/accountoverview/AccountOverview.tsx b/client/components/mma/accountoverview/AccountOverview.tsx index d2a5cde4d..278c9fe9a 100644 --- a/client/components/mma/accountoverview/AccountOverview.tsx +++ b/client/components/mma/accountoverview/AccountOverview.tsx @@ -32,6 +32,7 @@ import { GROUPED_PRODUCT_TYPES, PRODUCT_TYPES, } from '../../../../shared/productTypes'; +import { isExtraAccountsFlagEnabled } from '../../../utilities/extraAccounts'; import { useAccountDataLoader } from '../../../utilities/hooks/useAccountDataLoader'; import { GenericErrorScreen } from '../../shared/GenericErrorScreen'; import { NAV_LINKS } from '../../shared/nav/NavConfig'; @@ -42,6 +43,7 @@ import { DefaultLoadingView } from '../shared/asyncComponents/DefaultLoadingView import { DownloadAppCtaVariation1 } from '../shared/DownloadAppCtaVariation1'; import { DownloadEditionsAppCtaWithImage } from '../shared/DownloadEditionsAppCtaWithImage'; import { DownloadFeastAppCtaWithImage } from '../shared/DownloadFeastAppCtaWithImage'; +import { ExtraAccountsBanner } from '../shared/ExtraAccountsBanner'; import type { IsFromAppProps } from '../shared/IsFromAppProps'; import { NewspaperArchiveCta } from '../shared/NewspaperArchiveCta'; import { nonServiceableCountries } from '../shared/NonServiceableCountries'; @@ -103,6 +105,11 @@ export const BenefitsCtas = ({ email, productKeys }: BenefitsCtasProps) => { return ( <> + {/* TODO: remove the isExtraAccountsFlagEnabled() query-param check + once the Extra accounts feature ships; gate on Digital plus only. */} + {hasDigitalPack && isExtraAccountsFlagEnabled() && ( + + )} {(hasDigitalPlusPrint || isPlusDigitalProduct || hasGuardianEmail || diff --git a/client/components/mma/extraAccounts/ExtraAccountCancelInvitationModal.tsx b/client/components/mma/extraAccounts/ExtraAccountCancelInvitationModal.tsx new file mode 100644 index 000000000..98d1fb894 --- /dev/null +++ b/client/components/mma/extraAccounts/ExtraAccountCancelInvitationModal.tsx @@ -0,0 +1,276 @@ +import { css } from '@emotion/react'; +import { + from, + headlineBold24, + palette, + space, + textSans17, + textSansBold17, +} from '@guardian/source/foundations'; +import { Button } from '@guardian/source/react-components'; +import { useState } from 'react'; +import type { ExtraAccount } from '../../../stores/ExtraAccountsStore'; +import { useToastStore } from '../../../stores/ToastStore'; + +type ExtraAccountWithEmail = Extract< + ExtraAccount, + { status: 'pending' | 'active' } +>; + +const instigatorCss = css` + color: ${palette.brand[500]}; + font-weight: normal; +`; + +const overlayCss = css` + position: fixed; + inset: 0; + z-index: 9999; + display: flex; + align-items: flex-end; + justify-content: center; + background: rgba(18, 18, 18, 0.6); + + ${from.tablet} { + align-items: center; + justify-content: center; + } +`; + +const containerCss = css` + position: relative; + overflow: auto; + display: flex; + flex-direction: column; + justify-content: space-between; + width: 100%; + max-height: calc(100vh - ${space[6]}px); + padding: ${space[3]}px; + padding-bottom: ${space[8]}px; + background: ${palette.neutral[100]}; + border-radius: ${space[2]}px; + color: ${palette.neutral[7]}; + + ${from.tablet} { + max-width: 60%; + padding: ${space[3]}px ${space[5]}px ${space[6]}px ${space[5]}px; + } +`; + +const closeButtonCss = css` + display: flex; + padding: 0; + border: none; + background: transparent; + cursor: pointer; + color: ${palette.neutral[7]}; + margin-left: auto; + + svg { + width: 30px; + height: 30px; + fill: currentColor; + } +`; + +const titleCss = css` + ${headlineBold24}; + margin: 0; + margin-bottom: ${space[3]}px; +`; + +const bodyCss = css` + display: flex; + flex-direction: column; + gap: ${space[3]}px; +`; + +const paragraphCss = css` + ${textSans17}; + margin: 0; +`; + +const emailCss = css` + ${textSansBold17}; +`; + +const footerCss = css` + display: flex; + flex-direction: column-reverse; + gap: ${space[4]}px; + margin-top: ${space[8]}px; + + ${from.tablet} { + flex-direction: row; + justify-content: flex-end; + gap: ${space[5]}px; + } +`; + +const footerButtonCss = css` + justify-content: center; + width: 100%; + + ${from.tablet} { + width: auto; + } +`; + +interface ModalCopy { + title: string; + confirmLabel: string; + dismissLabel: string; + instigatorLabel: string; + successMessage: string; + confirm: (invitationCode: string) => Promise; +} + +const ModalBody = ({ + isActive, + email, + remainingInvitations, +}: { + isActive: boolean; + email: string; + remainingInvitations: number; +}) => { + const invitationsLabel = `${remainingInvitations} invitation${ + remainingInvitations === 1 ? '' : 's' + } remaining`; + + return ( +
+

+ If you {isActive ? 'remove access' : 'cancel this invitation'},{' '} + {email} won't be able to{' '} + {isActive ? 'use ' : 'accept it or access '}Guardian premium + benefits through your subscription. +

+

+ We'll let them know by email that their{' '} + {isActive + ? 'access has been removed' + : 'invitation has been cancelled'} + . You can re-invite them at any time. +

+

+ If you proceed, this invitation slot will become available again + and you'll have {invitationsLabel}. +

+
+ ); +}; + +interface ExtraAccountCancelInvitationModalProps { + account: ExtraAccountWithEmail; + cancelInvitation: (invitationCode: string) => Promise; + removeAccess: (invitationCode: string) => Promise; + isSubmitting: boolean; + remainingInvitations: number; +} + +export const ExtraAccountCancelInvitationModal = ({ + account, + cancelInvitation, + removeAccess, + isSubmitting, + remainingInvitations, +}: ExtraAccountCancelInvitationModalProps) => { + const [isOpen, setIsOpen] = useState(false); + const { showToast } = useToastStore(); + + const isActive = account.status === 'active'; + + const copy: ModalCopy = isActive + ? { + title: 'Remove access?', + confirmLabel: 'Remove access', + dismissLabel: 'Cancel', + instigatorLabel: 'Remove access', + successMessage: `Access removed for ${account.email}`, + confirm: removeAccess, + } + : { + title: 'Cancel invitation?', + confirmLabel: 'Yes, cancel invitation', + dismissLabel: 'Keep invitation', + instigatorLabel: 'Cancel invitation', + successMessage: `Invitation cancelled for ${account.email}`, + confirm: cancelInvitation, + }; + + const close = () => setIsOpen(false); + + const handleConfirm = () => { + void copy.confirm(account.invitationCode).then((ok) => { + if (ok) { + close(); + showToast({ message: copy.successMessage }); + } + }); + }; + + return ( + <> + + + {isOpen && ( +
+
e.stopPropagation()} + > + + +

{copy.title}

+ + + +
+ + +
+
+
+ )} + + ); +}; diff --git a/client/components/mma/extraAccounts/ExtraAccountInviteForm.tsx b/client/components/mma/extraAccounts/ExtraAccountInviteForm.tsx new file mode 100644 index 000000000..82d250d88 --- /dev/null +++ b/client/components/mma/extraAccounts/ExtraAccountInviteForm.tsx @@ -0,0 +1,176 @@ +import { css } from '@emotion/react'; +import { from, palette, space, textSans17 } from '@guardian/source/foundations'; +import { + Button, + Checkbox, + CheckboxGroup, + TextInput, +} from '@guardian/source/react-components'; +import { useState } from 'react'; +import { useWindowWidth } from '@/client/utilities/hooks/useWindowWidth'; +import { isEmail } from '../../../../shared/validationUtils'; + +const formCss = css` + margin: ${space[5]}px 0 ${space[4]}px 0; + width: 100%; + + ${from.tablet} { + max-width: 70%; + } +`; + +const formCssOverrides = css` + margin-top: 0; +`; + +const introCss = css` + ${textSans17}; + margin: 0 0 ${space[3]}px 0; +`; + +const checkboxBoxCss = css` + background-color: ${palette.neutral[97]}; + padding: ${space[3]}px; + margin: ${space[5]}px 0 ${space[5]}px 0; + border-radius: ${space[2]}px; +`; + +const actionsCss = css` + display: flex; + gap: ${space[4]}px; + flex-direction: column; + + ${from.tablet} { + flex-direction: row; + } +`; + +const sendInvitationCss = css` + color: ${palette.brand[500]}; + font-weight: normal; +`; + +interface ExtraAccountInviteFormProps { + isFormOpen: boolean; + onOpen: () => void; + onCancel: () => void; + onSent: (email: string) => void; + sendInvitation: (email: string) => Promise; + isSubmitting: boolean; +} + +export const ExtraAccountInviteForm = ({ + isFormOpen, + onOpen, + onCancel, + onSent, + sendInvitation, + isSubmitting, +}: ExtraAccountInviteFormProps) => { + const [email, setEmail] = useState(''); + const [confirmedConsent, setConfirmedConsent] = useState(false); + const [emailError, setEmailError] = useState(); + const [consentError, setConsentError] = useState(); + + const { windowWidthIsGreaterThan, windowWidthIsLessThan } = + useWindowWidth(); + const isFormOpenOnDesktop = + isFormOpen && windowWidthIsGreaterThan('tablet'); + const isFormOpenOnTablet = isFormOpen && windowWidthIsLessThan('tablet'); + + const handleSend = async () => { + const trimmedEmail = email.trim(); + const isEmailValid = !!trimmedEmail && isEmail(trimmedEmail); + const isConsentValid = confirmedConsent; + + setEmailError( + isEmailValid + ? undefined + : trimmedEmail + ? 'Please enter a valid email address.' + : 'Please enter an email address.', + ); + setConsentError( + isConsentValid + ? undefined + : 'Please tick this box to confirm before sending the invitation.', + ); + + if (!isEmailValid || !isConsentValid) { + return; + } + + const ok = await sendInvitation(trimmedEmail); + if (ok) { + onSent(trimmedEmail); + } + }; + + if (!isFormOpen) { + return ( + + ); + } + + return ( +
+ {isFormOpenOnDesktop && ( +

+ Enter the email address of the people you'd like to invite. +

+ )} + + { + setEmail(e.target.value); + if (emailError) { + setEmailError(undefined); + } + }} + /> + +
+ + { + setConfirmedConsent(e.target.checked); + if (e.target.checked && consentError) { + setConsentError(undefined); + } + }} + label="By ticking this box you're confirming that the person receiving this invitation is happy for the Guardian to email them." + name="consentConfirmation" + value="consentConfirmation" + /> + +
+ +
+ + +
+
+ ); +}; diff --git a/client/components/mma/extraAccounts/ExtraAccountRow.tsx b/client/components/mma/extraAccounts/ExtraAccountRow.tsx new file mode 100644 index 000000000..314310bbf --- /dev/null +++ b/client/components/mma/extraAccounts/ExtraAccountRow.tsx @@ -0,0 +1,237 @@ +import type { SerializedStyles } from '@emotion/react'; +import { css } from '@emotion/react'; +import { + from, + palette, + space, + textSans17, + textSansBold17, +} from '@guardian/source/foundations'; +import { + SvgPersonPlus, + SvgPersonRoundFilled, +} from '@guardian/source/react-components'; +import { useState } from 'react'; +import { useWindowWidth } from '@/client/utilities/hooks/useWindowWidth'; +import type { + ExtraAccount, + ExtraAccountStatus, +} from '../../../stores/ExtraAccountsStore'; +import { useToastStore } from '../../../stores/ToastStore'; +import { Pill } from '../../shared/Pill'; +import { ExtraAccountCancelInvitationModal } from './ExtraAccountCancelInvitationModal'; +import { ExtraAccountInviteForm } from './ExtraAccountInviteForm'; + +const rowCss = css` + display: flex; + align-items: center; + gap: ${space[2]}px; + padding: ${space[3]}px 0; +`; + +const rowCssOverrides = css` + flex-direction: column; +`; + +const userRowCss = css` + display: flex; + align-items: flex-start; + gap: ${space[2]}px; + padding: ${space[3]}px 0; + + ${from.tablet} { + align-items: center; + } +`; + +const avatarCss = css` + display: flex; + align-items: center; + justify-content: center; + + svg { + width: ${space[14]}px; + height: ${space[14]}px; + } +`; + +const emptyAvatarCss = css` + border: 2px solid ${palette.neutral[60]}; + border-radius: 50%; + margin: ${space[2]}px; + + svg { + width: ${space[9]}px; + height: ${space[9]}px; + } +`; + +const identityCss = css` + display: flex; + flex-direction: column; + align-items: flex-start; + gap: ${space[2]}px; + width: 100%; + margin-top: ${space[3]}px; + + ${from.tablet} { + flex-direction: row; + align-items: center; + margin-top: 0; + gap: ${space[3]}px; + } +`; + +const piiContainerCss = css` + display: flex; + flex-direction: column; + align-items: flex-start; + margin-right: auto; +`; + +const piiCss = css` + ${textSans17}; +`; + +const piiCssBold = css` + ${textSansBold17}; +`; + +const spacerCss = css` + height: ${space[6]}px; + border-right: 1px solid ${palette.neutral[86]}; + margin: 0 ${space[5]}px; +`; + +const introCss = css` + ${textSans17}; + margin: 0; +`; + +interface ExtraAccountRowProps { + account: ExtraAccount; + sendInvitation: (email: string) => Promise; + cancelInvitation: (invitationCode: string) => Promise; + removeAccess: (invitationCode: string) => Promise; + isSubmitting: boolean; + remainingInvitations: number; +} + +const Avatar = ({ + status, + cssOverrides, +}: { + status: ExtraAccountStatus; + cssOverrides?: SerializedStyles; +}) => { + if (status === 'empty') { + return ( + + + + ); + } + + return ( + + + + ); +}; + +export const ExtraAccountRow = ({ + account, + sendInvitation, + cancelInvitation, + removeAccess, + isSubmitting, + remainingInvitations, +}: ExtraAccountRowProps) => { + const [isFormOpen, setIsFormOpen] = useState(false); + const { showToast } = useToastStore(); + + const { windowWidthIsLessThan, windowWidthIsGreaterThan } = + useWindowWidth(); + const isFormOpenOnTablet = isFormOpen && windowWidthIsLessThan('tablet'); + + const avatarContainerCss = css` + align-self: ${isFormOpen ? 'flex-start' : 'center'}; + ${isFormOpenOnTablet + ? css` + display: flex; + flex-direction: row; + align-items: center; + ` + : undefined} + `; + + if (account.status === 'empty') { + return ( +
+
+ + {isFormOpenOnTablet && ( +

+ Enter the email address of the people you'd like to + invite. +

+ )} +
+ setIsFormOpen(true)} + sendInvitation={sendInvitation} + isSubmitting={isSubmitting} + onCancel={() => setIsFormOpen(false)} + onSent={(email) => { + setIsFormOpen(false); + showToast({ + message: `Invitation successfully sent to ${email}`, + }); + }} + /> +
+ ); + } + + const isActive = account.status === 'active'; + + return ( +
+ +
+
+ {isActive && {account.name}} + {account.email} +
+ + + {windowWidthIsGreaterThan('tablet') &&
} + +
+
+ ); +}; diff --git a/client/components/mma/extraAccounts/ExtraAccounts.tsx b/client/components/mma/extraAccounts/ExtraAccounts.tsx new file mode 100644 index 000000000..f6db57220 --- /dev/null +++ b/client/components/mma/extraAccounts/ExtraAccounts.tsx @@ -0,0 +1,207 @@ +import { css } from '@emotion/react'; +import { + from, + headlineBold28, + palette, + space, + textSans17, + textSansBold17, +} from '@guardian/source/foundations'; +import { + SvgPersonRoundOutlined, + SvgTickRound, +} from '@guardian/source/react-components'; +import { Fragment } from 'react'; +import { Navigate } from 'react-router-dom'; +import { useExtraAccounts } from '../../../utilities/hooks/useExtraAccounts'; +import { GenericErrorScreen } from '../../shared/GenericErrorScreen'; +import { NAV_LINKS } from '../../shared/nav/NavConfig'; +import { PageContainer } from '../Page'; +import { DefaultLoadingView } from '../shared/asyncComponents/DefaultLoadingView'; +import { ExtraAccountRow } from './ExtraAccountRow'; + +const MAX_ACCOUNTS = 3; + +const subHeadingCss = css` + ${headlineBold28}; + margin-top: ${space[5]}px; +`; + +const cardCss = css` + margin-top: ${space[5]}px; + border: 1px solid ${palette.neutral[86]}; + border-radius: ${space[2]}px; + overflow: hidden; +`; + +const introCss = css` + display: flex; + flex-direction: column-reverse; + justify-content: space-between; + gap: ${space[3]}px; + background-color: ${palette.neutral[97]}; + + ${from.tablet} { + flex-direction: row; + } +`; + +const introTextCss = css` + display: flex; + flex-direction: column; + justify-content: flex-start; + gap: ${space[2]}px; + flex: 2; + margin: ${space[3]}px; + margin-bottom: ${space[9]}px; + + p { + ${textSans17}; + margin: 0; + } +`; + +const bodyCss = css` + background-color: ${palette.neutral[100]}; + padding: ${space[3]}px; + padding-bottom: 0; +`; + +const dotsRowCss = css` + display: flex; + + svg { + width: ${space[6]}px; + height: ${space[6]}px; + } +`; + +const usageCss = css` + ${textSans17}; + margin: ${space[1]}px 0 ${space[3]}px 0; + + strong { + ${textSansBold17}; + } +`; + +const dividerCss = css` + border-top: 1px solid ${palette.neutral[86]}; +`; + +const imagePlaceholderCss = css` + width: 100%; + aspect-ratio: 25 / 9; + background-color: ${palette.neutral[86]}; + border-radius: ${space[2]}px; + align-self: center; + + ${from.tablet} { + flex: 1; + aspect-ratio: 5 / 3; + } +`; + +export const ExtraAccounts = () => { + const { + accounts, + isLoading, + hasError, + shouldRedirect, + sendInvitation, + cancelInvitation, + removeAccess, + isSubmitting, + } = useExtraAccounts(); + + if (shouldRedirect) { + return ; + } + + const usedCount = (accounts ?? []).filter( + (account) => account.status !== 'empty', + ).length; + + return ( + +

Manage extra accounts

+ + {hasError ? ( + + ) : isLoading || !accounts ? ( + + ) : ( +
+
+
+

+ You can share your subscription with up to{' '} + {MAX_ACCOUNTS} people. +

+

+ Each account is individual. Your account data or + billing information are not shared with the + people you invite. +

+
+
+
+ +
+
+ {Array.from({ length: MAX_ACCOUNTS }).map( + (_, index) => + index < usedCount ? ( + + ) : ( + + ), + )} +
+ +

+ + {usedCount}/{MAX_ACCOUNTS} invitation + {' '} + being used +

+ + {accounts.map((account, index) => ( + +
+ + + ))} +
+
+ )} + + ); +}; diff --git a/client/components/mma/shared/ExtraAccountsBanner.tsx b/client/components/mma/shared/ExtraAccountsBanner.tsx new file mode 100644 index 000000000..83d6229de --- /dev/null +++ b/client/components/mma/shared/ExtraAccountsBanner.tsx @@ -0,0 +1,90 @@ +import { css } from '@emotion/react'; +import { + from, + palette, + space, + textSans17, + textSansBold20, + until, +} from '@guardian/source/foundations'; +import { Button } from '@guardian/source/react-components'; +import { useNavigate } from 'react-router-dom'; + +const containerCss = css` + margin-top: ${space[10]}px; + + display: flex; + gap: ${space[3]}px; + flex-direction: column-reverse; + justify-content: space-between; + border-radius: ${space[2]}px; + background-color: ${palette.neutral[97]}; + + ${from.tablet} { + flex-direction: row; + } +`; + +const copyContainerCss = css` + padding: ${space[3]}px; + padding-bottom: ${space[6]}px; + + h4 { + ${textSansBold20}; + margin: 0; + } + + p { + ${textSans17}; + margin: 0; + } +`; + +const buttonCss = css` + margin-top: ${space[5]}px; + + ${until.tablet} { + width: 100%; + } +`; + +// Placeholder image slot - a correctly sized div to be replaced with the +// final asset later. +const imagePlaceholderCss = css` + width: 100%; + aspect-ratio: 5 / 3; + background-color: ${palette.neutral[86]}; + border-radius: ${space[2]}px; + align-self: center; + + ${from.tablet} { + flex: 1; + max-width: 250px; + aspect-ratio: 4 / 3; + } +`; + +export const ExtraAccountsBanner = () => { + const navigate = useNavigate(); + + return ( +
+
+

Extra accounts

+

+ Share your subscription with up to 3 people + . +

+ +
+
+
+ ); +}; diff --git a/client/components/mma/shared/assets/ExtraAccountsIcon.tsx b/client/components/mma/shared/assets/ExtraAccountsIcon.tsx new file mode 100644 index 000000000..0ee4a3ca9 --- /dev/null +++ b/client/components/mma/shared/assets/ExtraAccountsIcon.tsx @@ -0,0 +1,24 @@ +export const ExtraAccountsIcon = () => ( + + + + + +); diff --git a/client/components/shared/Main.tsx b/client/components/shared/Main.tsx index edbb69bad..b8fa453ee 100644 --- a/client/components/shared/Main.tsx +++ b/client/components/shared/Main.tsx @@ -6,6 +6,7 @@ import type { SignInStatus } from '../../utilities/signInStatus'; import { Footer } from './footer/Footer'; import { MinimalFooter } from './footer/MinimalFooter'; import { Header } from './Header'; +import { ToastContainer } from './ToastContainer'; export interface MainProps { signInStatus?: SignInStatus; @@ -81,6 +82,7 @@ export const Main = ({ ) : (
)} +
); diff --git a/client/components/shared/ToastContainer.tsx b/client/components/shared/ToastContainer.tsx new file mode 100644 index 000000000..7ac201f06 --- /dev/null +++ b/client/components/shared/ToastContainer.tsx @@ -0,0 +1,147 @@ +import type { SerializedStyles } from '@emotion/react'; +import { css } from '@emotion/react'; +import { + from, + palette, + space, + textSansBold17, +} from '@guardian/source/foundations'; +import { + SvgAlertRound, + SvgCross, + SvgInfoRound, + SvgTickRound, +} from '@guardian/source/react-components'; +import type { ReactNode } from 'react'; +import type { ToastSeverity } from '../../stores/ToastStore'; +import { useToastStore } from '../../stores/ToastStore'; + +interface ToastVariant { + icon: ReactNode; + accentColour: string; + textColour: string; +} + +const toastVariants: Record = { + success: { + icon: , + accentColour: palette.success[400], + textColour: palette.success[400], + }, + error: { + icon: , + accentColour: palette.error[400], + textColour: palette.error[400], + }, + info: { + icon: , + accentColour: palette.brand[500], + textColour: palette.neutral[7], + }, +}; + +const containerCss = css` + position: fixed; + left: 22px; + bottom: 53px; + z-index: 9999; + max-width: 80%; + width: 100%; + + ${from.tablet} { + left: 71px; + min-width: 622px; + width: unset; + } +`; + +const toastCss = (variant: ToastVariant) => css` + display: flex; + align-items: center; + gap: ${space[1]}px; + padding: ${space[4]}px; + border-radius: ${space[2]}px; + border: 1px solid ${variant.accentColour}; + background-color: ${palette.neutral[100]}; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.18); +`; + +const iconWrapperCss = (variant: ToastVariant) => css` + display: flex; + align-items: center; + justify-content: center; + + svg { + fill: ${variant.accentColour}; + width: ${space[8]}px; + height: ${space[8]}px; + } +`; + +const bodyCss = (variant: ToastVariant) => css` + flex: 1; + ${textSansBold17}; + line-height: 1.35; + color: ${variant.textColour}; + + strong { + ${textSansBold17}; + } +`; + +const ToastBody = ({ + children, + cssOverrides, +}: { + children: string; + cssOverrides: SerializedStyles; +}) =>
{children}
; + +const closeButtonCss = css` + border: none; + background: transparent; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + color: ${palette.neutral[7]}; + + svg { + fill: ${palette.neutral[7]}; + width: ${space[6]}px; + height: ${space[8]}px; + } +`; + +export const ToastContainer = () => { + const { isOpen, current, dismissToast } = useToastStore(); + + if (!isOpen || !current) { + return null; + } + + const variant = toastVariants[current.severity]; + + return ( +
+
+
{variant.icon}
+ {typeof current.message === 'string' ? ( + + {current.message} + + ) : ( + current.message + )} + +
+
+ ); +}; diff --git a/client/components/shared/nav/DropdownNav.tsx b/client/components/shared/nav/DropdownNav.tsx index 5f606e7b6..d285c9e2a 100644 --- a/client/components/shared/nav/DropdownNav.tsx +++ b/client/components/shared/nav/DropdownNav.tsx @@ -2,7 +2,9 @@ import { css } from '@emotion/react'; import { from, neutral, palette, space } from '@guardian/source/foundations'; import { useEffect, useRef, useState } from 'react'; import { Link } from 'react-router-dom'; +import { useAccountStore } from '../../../stores/AccountStore'; import { gridItemPlacement } from '../../../styles/grid'; +import { isExtraAccountsEnabled } from '../../../utilities/extraAccounts'; import { ProfileIcon } from '../../mma/shared/assets/ProfileIcon'; import { expanderButtonCss } from '../ExpanderButton'; import type { MenuSpecificNavItem } from './NavConfig'; @@ -138,6 +140,8 @@ export const DropdownNav = (props: { isHelpCentrePage: boolean }) => { const [showMenu, setShowMenu] = useState(false); const wrapperRef = useRef(null); const buttonRef = useRef(null); + const mdapiResponse = useAccountStore((state) => state.mdapiResponse); + const showExtraAccounts = isExtraAccountsEnabled(mdapiResponse); useEffect(() => { addListeners(); @@ -238,8 +242,13 @@ export const DropdownNav = (props: { isHelpCentrePage: boolean }) => {
    - {Object.values(NAV_LINKS).map( - (navItem: MenuSpecificNavItem) => ( + {Object.values(NAV_LINKS) + .filter( + (navItem) => + navItem !== NAV_LINKS.extraAccounts || + showExtraAccounts, + ) + .map((navItem: MenuSpecificNavItem) => (
  • {navItem.local && !props.isHelpCentrePage ? ( { )}
  • - ), - )} + ))}
); diff --git a/client/components/shared/nav/LeftSideNav.tsx b/client/components/shared/nav/LeftSideNav.tsx index f99b1952d..cc933ca52 100644 --- a/client/components/shared/nav/LeftSideNav.tsx +++ b/client/components/shared/nav/LeftSideNav.tsx @@ -1,6 +1,8 @@ import { css } from '@emotion/react'; import { from, palette, space, textSans20 } from '@guardian/source/foundations'; import { Link } from 'react-router-dom'; +import { useAccountStore } from '../../../stores/AccountStore'; +import { isExtraAccountsEnabled } from '../../../utilities/extraAccounts'; import type { MenuSpecificNavItem, NavItem } from './NavConfig'; import { NAV_LINKS, PROFILE_HOST_NAME } from './NavConfig'; @@ -78,45 +80,55 @@ export interface LeftSideNavProps { selectedNavItem?: NavItem; } -export const LeftSideNav = (props: LeftSideNavProps) => ( -
    - {Object.values(NAV_LINKS) - .filter((navItem) => !navItem.isDropDownExclusive) - .map((navItem: MenuSpecificNavItem) => ( -
  • - {navItem.local ? ( - - {navItem.icon && ( - - - - )} - {navItem.title} - - ) : ( - - {navItem.title} - - )} -
  • - ))} -
-); +export const LeftSideNav = (props: LeftSideNavProps) => { + const mdapiResponse = useAccountStore((state) => state.mdapiResponse); + const showExtraAccounts = isExtraAccountsEnabled(mdapiResponse); + + return ( +
    + {Object.values(NAV_LINKS) + .filter((navItem) => !navItem.isDropDownExclusive) + .filter( + (navItem) => + navItem !== NAV_LINKS.extraAccounts || + showExtraAccounts, + ) + .map((navItem: MenuSpecificNavItem) => ( +
  • + {navItem.local ? ( + + {navItem.icon && ( + + + + )} + {navItem.title} + + ) : ( + + {navItem.title} + + )} +
  • + ))} +
+ ); +}; diff --git a/client/components/shared/nav/NavConfig.tsx b/client/components/shared/nav/NavConfig.tsx index f6011f5ea..38e8facac 100644 --- a/client/components/shared/nav/NavConfig.tsx +++ b/client/components/shared/nav/NavConfig.tsx @@ -3,6 +3,7 @@ import { conf } from '../../../../server/config'; import { AccountOverviewIcon } from '../../mma/shared/assets/AccountOverviewIcon'; import { CreditCardIcon } from '../../mma/shared/assets/CreditCardIcon'; import { EmailPrefsIcon } from '../../mma/shared/assets/EmailPrefIcon'; +import { ExtraAccountsIcon } from '../../mma/shared/assets/ExtraAccountsIcon'; import { HelpIcon } from '../../mma/shared/assets/HelpIcon'; import { ProfileIcon } from '../../mma/shared/assets/ProfileIcon'; import { SettingsIcon } from '../../mma/shared/assets/SettingsIcon'; @@ -26,6 +27,7 @@ export interface MenuSpecificNavItem extends NavItem { interface NavLinks { accountOverview: MenuSpecificNavItem; + extraAccounts: MenuSpecificNavItem; billing: MenuSpecificNavItem; dataPrivacy: MenuSpecificNavItem; profile: MenuSpecificNavItem; @@ -51,6 +53,12 @@ export const NAV_LINKS: NavLinks = { local: true, icon: AccountOverviewIcon, }, + extraAccounts: { + title: 'Extra accounts', + link: '/extra-accounts', + local: true, + icon: ExtraAccountsIcon, + }, billing: { title: 'Billing', link: '/billing', diff --git a/client/stores/ExtraAccountsStore.tsx b/client/stores/ExtraAccountsStore.tsx new file mode 100644 index 000000000..353b99607 --- /dev/null +++ b/client/stores/ExtraAccountsStore.tsx @@ -0,0 +1,85 @@ +import { create } from 'zustand'; +import { devtools } from 'zustand/middleware'; + +export type ExtraAccountStatus = 'empty' | 'pending' | 'active'; + +interface ExtraAccountEmpty { + status: 'empty'; +} + +interface ExtraAccountPending { + status: 'pending'; + email: string; + invitationCode: string; +} + +interface ExtraAccountActive { + status: 'active'; + email: string; + name: string; + invitationCode: string; +} + +export type ExtraAccount = + | ExtraAccountEmpty + | ExtraAccountPending + | ExtraAccountActive; + +export enum ExtraAccountsLoadingState { + NotStarted = 'NotStarted', + Loading = 'Loading', + Loaded = 'Loaded', + Error = 'Error', +} + +interface ExtraAccountsState { + accounts: ExtraAccount[] | null; + loadingState: ExtraAccountsLoadingState; + error: string | null; +} + +interface ExtraAccountsActions { + setAccounts: (accounts: ExtraAccount[]) => void; + setLoadingState: (state: ExtraAccountsLoadingState) => void; + setError: (error: string | null) => void; + clearStore: () => void; +} + +type ExtraAccountsStore = ExtraAccountsState & ExtraAccountsActions; + +const initialState: ExtraAccountsState = { + accounts: null, + loadingState: ExtraAccountsLoadingState.NotStarted, + error: null, +}; + +export const useExtraAccountsStore = create()( + devtools( + (set) => ({ + ...initialState, + setAccounts: (accounts) => + set( + { + accounts, + loadingState: ExtraAccountsLoadingState.Loaded, + error: null, + }, + false, + 'setAccounts', + ), + setLoadingState: (state) => + set({ loadingState: state }, false, 'setLoadingState'), + setError: (error) => + set( + { + error, + loadingState: ExtraAccountsLoadingState.Error, + }, + false, + 'setError', + ), + clearStore: () => set(initialState, false, 'clearStore'), + }), + { name: 'ExtraAccountsStore' }, + ), +); diff --git a/client/stores/ToastStore.ts b/client/stores/ToastStore.ts new file mode 100644 index 000000000..1be2f4753 --- /dev/null +++ b/client/stores/ToastStore.ts @@ -0,0 +1,76 @@ +import type { ReactNode } from 'react'; +import { create } from 'zustand'; +import { devtools } from 'zustand/middleware'; + +export type ToastSeverity = 'success' | 'error' | 'info'; + +export interface Toast { + message: ReactNode | string; + severity: ToastSeverity; +} + +interface ToastState { + current: Toast | null; + isOpen: boolean; + timeoutId: number | null; +} + +interface ToastActions { + showToast: (args: { + message: ReactNode | string; + severity?: ToastSeverity; + }) => void; + dismissToast: () => void; +} + +type ToastStore = ToastState & ToastActions; + +const initialState: ToastState = { + current: null, + isOpen: false, + timeoutId: null, +}; + +const AUTO_DISMISS_MS = 5000; + +export const useToastStore = create()( + devtools( + (set, get) => ({ + ...initialState, + showToast: ({ message, severity = 'success' }) => { + const prevTimeoutId = get().timeoutId; + if (prevTimeoutId) { + window.clearTimeout(prevTimeoutId); + } + + const timeoutId = window.setTimeout(() => { + get().dismissToast(); + }, AUTO_DISMISS_MS); + + set( + { + current: { message, severity }, + isOpen: true, + timeoutId, + }, + false, + 'showToast', + ); + }, + dismissToast: () => { + const prevTimeoutId = get().timeoutId; + if (prevTimeoutId) { + window.clearTimeout(prevTimeoutId); + } + set( + { + ...initialState, + }, + false, + 'dismissToast', + ); + }, + }), + { name: 'ToastStore' }, + ), +); diff --git a/client/utilities/extraAccounts.ts b/client/utilities/extraAccounts.ts new file mode 100644 index 000000000..4fff18cd8 --- /dev/null +++ b/client/utilities/extraAccounts.ts @@ -0,0 +1,44 @@ +import type { + MembersDataApiResponse, + ProductDetail, +} from '../../shared/productResponse'; +import { isProduct, isSpecificProductType } from '../../shared/productResponse'; +import { PRODUCT_TYPES } from '../../shared/productTypes'; + +// TODO: remove this query-param check once the Extra accounts feature ships. +// The long-term gate is the Digital plus product check only. +export const isExtraAccountsFlagEnabled = (): boolean => { + if (typeof window === 'undefined') { + return false; + } + + return ( + new URLSearchParams(window.location.search).get( + 'TEST_EXTRA_ACCOUNTS_FLAG', + ) === 'true' + ); +}; + +export const getDigitalPlusProduct = ( + mdapiResponse: MembersDataApiResponse | null, +): ProductDetail | undefined => + mdapiResponse?.products + .filter(isProduct) + .find( + (product) => + !product.subscription.cancelledAt && + isSpecificProductType( + product.mmaProductKey, + PRODUCT_TYPES.digipack, + ), + ); + +export const hasDigitalPlus = ( + mdapiResponse: MembersDataApiResponse | null, +): boolean => !!getDigitalPlusProduct(mdapiResponse); + +// TODO: Remove this query-param check once the Extra accounts feature ships. +// The long-term gate is the Digital plus product check only. +export const isExtraAccountsEnabled = ( + mdapiResponse: MembersDataApiResponse | null, +): boolean => hasDigitalPlus(mdapiResponse) && isExtraAccountsFlagEnabled(); diff --git a/client/utilities/hooks/useExtraAccounts.ts b/client/utilities/hooks/useExtraAccounts.ts new file mode 100644 index 000000000..6c409942e --- /dev/null +++ b/client/utilities/hooks/useExtraAccounts.ts @@ -0,0 +1,363 @@ +import * as Sentry from '@sentry/browser'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { MDA_TEST_USER_HEADER } from '../../../shared/productResponse'; +import type { ExtraAccount } from '../../stores/ExtraAccountsStore'; +import { + ExtraAccountsLoadingState, + useExtraAccountsStore, +} from '../../stores/ExtraAccountsStore'; +import { useToastStore } from '../../stores/ToastStore'; +import { trackEvent } from '../analytics'; +import { getDigitalPlusProduct } from '../extraAccounts'; +import { fetchWithDefaultParameters } from '../fetch'; +import { useAccountDataLoader } from './useAccountDataLoader'; + +const MAX_ACCOUNTS = 3; +const EXTRA_ACCOUNTS_BASE = '/api/extra-accounts'; + +const requestHeaders = (isTestUser: boolean) => ({ + 'Content-Type': 'application/json', + [MDA_TEST_USER_HEADER]: `${isTestUser}`, +}); + +interface InvitationResponseItem { + subscriptionName: string; + invitationCode: string; + primaryIdentityId: string; + secondaryUserEmail: string; + secondaryIdentityId: string; + invitedDate: string; + expiryDate: number; +} + +interface SecondaryUserResponseItem { + subscriptionName: string; + secondaryIdentityId: string; + primaryIdentityId: string; + acceptedDate: string; + email: string; + displayName: string; + firstName?: string; + lastName?: string; +} + +interface MmaPrimaryResponse { + invitations: InvitationResponseItem[]; + secondaryUsers: SecondaryUserResponseItem[]; +} + +const fetchMmaPrimary = async ( + subscriptionName: string, + isTestUser: boolean, +): Promise => { + const response = await fetchWithDefaultParameters( + `${EXTRA_ACCOUNTS_BASE}/${subscriptionName}/mma-primary`, + { headers: requestHeaders(isTestUser) }, + ); + if (!response.ok) { + throw new Error(`Failed to load extra accounts (${response.status})`); + } + const body = (await response.json()) as Partial; + return { + invitations: body.invitations ?? [], + secondaryUsers: body.secondaryUsers ?? [], + }; +}; + +export const fetchExtraAccounts = async ( + subscriptionName: string, + isTestUser: boolean, +): Promise => { + const { secondaryUsers, invitations } = await fetchMmaPrimary( + subscriptionName, + isTestUser, + ); + + const activeAccounts: ExtraAccount[] = secondaryUsers.map( + (user): ExtraAccount => ({ + status: 'active', + name: user.displayName, + email: user.email, + // The mma-primary endpoint does not return an invitationCode for + // active secondary users, so remove-access cannot currently target + // them until the backend exposes a way to do so. + invitationCode: '', + }), + ); + + const pendingAccounts: ExtraAccount[] = invitations.map( + (invitation): ExtraAccount => ({ + status: 'pending', + email: invitation.secondaryUserEmail, + invitationCode: invitation.invitationCode, + }), + ); + + const usedAccounts = [...activeAccounts, ...pendingAccounts]; + const emptySlots: ExtraAccount[] = Array.from( + { length: Math.max(0, MAX_ACCOUNTS - usedAccounts.length) }, + () => ({ status: 'empty' as const }), + ); + + return [...usedAccounts, ...emptySlots]; +}; + +export const sendInvitationRequest = async ( + subscriptionName: string, + email: string, + isTestUser: boolean, +): Promise => { + const response = await fetchWithDefaultParameters( + `${EXTRA_ACCOUNTS_BASE}/invitation`, + { + method: 'POST', + headers: requestHeaders(isTestUser), + body: JSON.stringify({ + subscriptionName, + secondaryUserEmail: email, + }), + }, + ); + if (!response.ok) { + const message = await response.text().catch(() => ''); + throw new Error( + message || `Failed to send invitation (${response.status})`, + ); + } +}; + +export const deleteInvitationRequest = async ( + invitationCode: string, + isTestUser: boolean, +): Promise => { + const response = await fetchWithDefaultParameters( + `${EXTRA_ACCOUNTS_BASE}/invitation/${invitationCode}`, + { + method: 'DELETE', + headers: requestHeaders(isTestUser), + }, + ); + if (!response.ok) { + const message = await response.text().catch(() => ''); + throw new Error( + message || `Failed to delete invitation (${response.status})`, + ); + } +}; + +interface UseExtraAccountsReturn { + accounts: ExtraAccount[] | null; + isLoading: boolean; + hasError: boolean; + shouldRedirect: boolean; + sendInvitation: (email: string) => Promise; + cancelInvitation: (invitationCode: string) => Promise; + removeAccess: (invitationCode: string) => Promise; + isSubmitting: boolean; +} + +export const useExtraAccounts = (): UseExtraAccountsReturn => { + const { accounts, loadingState, setAccounts, setLoadingState, setError } = + useExtraAccountsStore(); + const { showToast } = useToastStore(); + + const { + loadAccountData, + isLoading: isAccountLoading, + hasError: hasAccountError, + mdapiResponse, + } = useAccountDataLoader(); + + const [shouldRedirect, setShouldRedirect] = useState(false); + const [isSubmitting, setIsSubmitting] = useState(false); + const hasStartedLoading = useRef(false); + + const digitalPlusProduct = getDigitalPlusProduct(mdapiResponse); + const subscriptionName = + digitalPlusProduct?.subscription.subscriptionId ?? null; + const isTestUser = digitalPlusProduct?.isTestUser ?? false; + + const storeHasData = loadingState === ExtraAccountsLoadingState.Loaded; + + useEffect(() => { + if ( + storeHasData || + shouldRedirect || + hasStartedLoading.current || + loadingState === ExtraAccountsLoadingState.Error + ) { + return; + } + + if (hasAccountError) { + setError('Failed to load account data'); + return; + } + + if (!mdapiResponse && !isAccountLoading) { + void loadAccountData(); + return; + } + + if (isAccountLoading || !mdapiResponse) { + return; + } + + hasStartedLoading.current = true; + + if (!subscriptionName) { + setShouldRedirect(true); + return; + } + + const loadExtraAccounts = async () => { + setLoadingState(ExtraAccountsLoadingState.Loading); + try { + const data = await fetchExtraAccounts( + subscriptionName, + isTestUser, + ); + setAccounts(data); + } catch (error) { + const message = + error instanceof Error ? error.message : 'Unknown error'; + trackEvent({ + eventCategory: 'extraAccountsLoader', + eventAction: 'error', + eventLabel: message, + }); + Sentry.captureException(error); + setError(message); + } + }; + + void loadExtraAccounts(); + }, [ + storeHasData, + shouldRedirect, + loadingState, + isAccountLoading, + hasAccountError, + mdapiResponse, + subscriptionName, + isTestUser, + loadAccountData, + setAccounts, + setLoadingState, + setError, + ]); + + const refreshAccounts = useCallback(async () => { + if (!subscriptionName) { + return; + } + const data = await fetchExtraAccounts(subscriptionName, isTestUser); + setAccounts(data); + }, [subscriptionName, isTestUser, setAccounts]); + + const handleActionError = useCallback( + (error: unknown, fallbackMessage: string) => { + Sentry.captureException( + error instanceof Error ? error : new Error(fallbackMessage), + ); + showToast({ + message: + error instanceof Error && error.message + ? error.message + : 'Something went wrong. Please try again.', + severity: 'error', + }); + }, + [showToast], + ); + + const sendInvitation = useCallback( + async (email: string): Promise => { + if (isSubmitting || !subscriptionName) { + return false; + } + + setIsSubmitting(true); + + try { + await sendInvitationRequest( + subscriptionName, + email, + isTestUser, + ); + await refreshAccounts(); + return true; + } catch (error) { + handleActionError(error, 'Failed to send invitation'); + return false; + } finally { + setIsSubmitting(false); + } + }, + [ + subscriptionName, + isTestUser, + isSubmitting, + refreshAccounts, + handleActionError, + ], + ); + + const cancelInvitation = useCallback( + async (invitationCode: string): Promise => { + if (isSubmitting) { + return false; + } + + setIsSubmitting(true); + + try { + await deleteInvitationRequest(invitationCode, isTestUser); + await refreshAccounts(); + return true; + } catch (error) { + handleActionError(error, 'Failed to cancel invitation'); + return false; + } finally { + setIsSubmitting(false); + } + }, + [isTestUser, isSubmitting, refreshAccounts, handleActionError], + ); + + const removeAccess = useCallback( + async (invitationCode: string): Promise => { + if (isSubmitting) { + return false; + } + + setIsSubmitting(true); + + try { + await deleteInvitationRequest(invitationCode, isTestUser); + await refreshAccounts(); + return true; + } catch (error) { + handleActionError(error, 'Failed to remove access'); + return false; + } finally { + setIsSubmitting(false); + } + }, + [isTestUser, isSubmitting, refreshAccounts, handleActionError], + ); + + return { + accounts, + isLoading: + !storeHasData && + !shouldRedirect && + loadingState !== ExtraAccountsLoadingState.Error, + hasError: loadingState === ExtraAccountsLoadingState.Error, + shouldRedirect, + sendInvitation, + cancelInvitation, + removeAccess, + isSubmitting, + }; +}; diff --git a/server/apiGatewayDiscovery.ts b/server/apiGatewayDiscovery.ts index 135fa1fd0..08bbd7414 100644 --- a/server/apiGatewayDiscovery.ts +++ b/server/apiGatewayDiscovery.ts @@ -24,7 +24,8 @@ type ApiName = | 'product-move-api' | 'discount-api' | 'product-switch-api' - | 'update-supporter-plus-amount'; + | 'update-supporter-plus-amount' + | 'multiple-account-api'; const isProd = conf.STAGE.toUpperCase() === 'PROD'; const normalUserApiStage = isProd ? 'PROD' : 'CODE'; @@ -248,6 +249,13 @@ const invoicingAPIGateway = getApiGateway( ); export const invoicingAPI = invoicingAPIGateway.authorisedExpressCallback; +const multipleAccountAPIGateway = getApiGateway( + 'support', + 'multiple-account-api', +); +export const multipleAccountAPI = + multipleAccountAPIGateway.authorisedExpressCallback; + // not sure why this doesn't follow the pattern above export const getContactUsAPIHostAndKey = async () => { const stage = conf.STAGE.toUpperCase() === 'PROD' ? 'PROD' : 'CODE'; diff --git a/server/routes/api.ts b/server/routes/api.ts index 394affab6..2da0953d6 100644 --- a/server/routes/api.ts +++ b/server/routes/api.ts @@ -8,6 +8,7 @@ import { discountAPI, holidayStopAPI, invoicingAPI, + multipleAccountAPI, productMoveAPI, productSwitchAPI, updateSupporterPlusAmountAPI, @@ -407,4 +408,25 @@ router.get( userBenefitsApiHandler('benefits/me', 'USER_BENEFITS'), ); +router.get( + '/extra-accounts/:subscriptionName/mma-primary', + multipleAccountAPI( + 'subscriptions/:subscriptionName/mma-primary', + 'GET_MMA_PRIMARY_SUMMARY', + ['subscriptionName'], + ), +); +router.post( + '/extra-accounts/invitation', + withOktaServerSideValidation, + multipleAccountAPI('invitation', 'CREATE_INVITATION'), +); +router.delete( + '/extra-accounts/invitation/:invitationCode', + withOktaServerSideValidation, + multipleAccountAPI('invitation/:invitationCode', 'DELETE_INVITATION', [ + 'invitationCode', + ]), +); + export { router };