client/components/mma/accountoverview/ProductCard.tsx (613 lines of code) (raw):

import { css } from '@emotion/react'; import { palette, textSans17 } from '@guardian/source/foundations'; import { Button, Stack, SvgGift, SvgInfoRound, themeButtonReaderRevenueBrand, } from '@guardian/source/react-components'; import { InfoSummary, SuccessSummary, } from '@guardian/source-development-kitchen/react-components'; import { Link, useNavigate } from 'react-router-dom'; import { cancellationFormatDate, DATE_FNS_LONG_OUTPUT_FORMAT, parseDate, } from '@/shared/dates'; import type { MembersDataApiUser, PaidSubscriptionPlan, ProductDetail, Subscription, } from '@/shared/productResponse'; import { getMainPlan, getSpecificProductType, isGift, isPaidSubscriptionPlan, } from '@/shared/productResponse'; import { GROUPED_PRODUCT_TYPES } from '@/shared/productTypes'; import { wideButtonLayoutCss } from '../../../styles/ButtonStyles'; import { trackEvent } from '../../../utilities/analytics'; import { Ribbon } from '../../shared/Ribbon'; import { ErrorIcon } from '../shared/assets/ErrorIcon'; import { BenefitsToggle } from '../shared/benefits/BenefitsToggle'; import { Card } from '../shared/Card'; import { CardDisplay } from '../shared/CardDisplay'; import { DirectDebitDisplay } from '../shared/DirectDebitDisplay'; import { getNextPaymentDetails } from '../shared/NextPaymentDetails'; import { PaypalDisplay } from '../shared/PaypalDisplay'; import { SepaDisplay } from '../shared/SepaDisplay'; import { productCardConfiguration } from './ProductCardConfiguration'; import { keyValueCss, productCardTitleCss, productDetailLayoutCss, sectionHeadingCss, } from './ProductCardStyles'; const PaymentMethod = ({ subscription, inPaymentFailure, }: { subscription: Subscription; inPaymentFailure: boolean; }) => ( <div css={css` ${textSans17} `} data-qm-masking="blocklist" > {subscription.card && ( <CardDisplay inErrorState={inPaymentFailure} cssOverrides={css` margin: 0; `} {...subscription.card} /> )} {subscription.payPalEmail && ( <PaypalDisplay inline={true} payPalId={subscription.payPalEmail} /> )} {subscription.sepaMandate && ( <SepaDisplay inline={true} accountName={subscription.sepaMandate.accountName} iban={subscription.sepaMandate.iban} /> )} {subscription.mandate && ( <DirectDebitDisplay inline={true} {...subscription.mandate} /> )} {subscription.stripePublicKeyForCardAddition && ( <span>No Payment Method</span> )} </div> ); const NewPriceAlert = () => { const iconCss = css` svg { position: relative; top: 7px; margin-left: -4px; fill: ${palette.brand[500]}; } `; return ( <span css={iconCss}> <SvgInfoRound size="small" /> New price |{' '} </span> ); }; export const ProductCard = ({ productDetail, isEligibleToSwitch, user, }: { productDetail: ProductDetail; isEligibleToSwitch: boolean; user?: MembersDataApiUser; }) => { const navigate = useNavigate(); const mainPlan = getMainPlan(productDetail.subscription); if (!mainPlan) { throw new Error('mainPlan does not exist in ProductCard'); } const specificProductType = getSpecificProductType(productDetail.tier); const groupedProductType = GROUPED_PRODUCT_TYPES[specificProductType.groupedProductType]; const isPatron = productDetail.subscription.readerType === 'Patron'; const entitledToEvents = ['Partner', 'Patron'].includes(productDetail.tier) && (mainPlan as PaidSubscriptionPlan).features.includes('Events'); const productTitle = `${specificProductType.productTitle(mainPlan)}${ isPatron ? ' — Patron' : '' }`; const isGifted = isGift(productDetail.subscription); const userIsGifter = isGifted && productDetail.isPaidTier; const giftPurchaseDate = productDetail.subscription.lastPaymentDate; const shouldShowJoinDateNotStartDate = groupedProductType.shouldShowJoinDateNotStartDate; const shouldShowStartDate = !( shouldShowJoinDateNotStartDate || userIsGifter ); const subscriptionStartDate = productDetail.subscription.start; const subscriptionEndDate = productDetail.subscription.end; const hasCancellationPending = productDetail.subscription.cancelledAt; const isSafeToUpdatePaymentMethod = productDetail.subscription.safeToUpdatePaymentMethod; const hasPaymentFailure = !!productDetail.alertText; const nextPaymentDetails = getNextPaymentDetails( mainPlan, productDetail.subscription, null, hasPaymentFailure, ); const showSwitchButton = isEligibleToSwitch && !hasCancellationPending && specificProductType.productType === 'contributions'; const productBenefits = specificProductType.productType === 'supporterplus' ? 'supporter benefits' : groupedProductType.friendlyName; const cardConfig = productCardConfiguration[specificProductType.productType]; const giftRibbonColour = cardConfig.invertText ? palette.brand[400] : palette.brandAlt[400]; const giftRibbonCopyColour = cardConfig.invertText ? palette.brandAlt[400] : palette.brand[400]; const giftRibbonCss = css` position: absolute; top: 50%; transform: translateY(-50%); right: 0; `; const benefitsTextCss = css` ${textSans17}; margin: 0; max-width: 35ch; `; const canBeInOfferPeriod = specificProductType.productType === 'supporterplus'; const canBeInPausePeriod = specificProductType.productType === 'contributions'; const isInOfferOrPausePeriod = !hasCancellationPending && productDetail.subscription.nextPaymentDate && productDetail.subscription.potentialCancellationDate && productDetail.subscription.nextPaymentDate !== productDetail.subscription.potentialCancellationDate; return ( <Stack space={4}> {hasCancellationPending && productDetail.subscription.end && ( <InfoSummary message={`Your ${groupedProductType.friendlyName} has been cancelled`} context={ <> You are able to access your {productBenefits} until{' '} <strong> {cancellationFormatDate( productDetail.subscription .cancellationEffectiveDate, DATE_FNS_LONG_OUTPUT_FORMAT, )} </strong> </> } /> )} {canBeInOfferPeriod && isInOfferOrPausePeriod && isPaidSubscriptionPlan(mainPlan) && mainPlan.billingPeriod === 'month' && ( <SuccessSummary message="Your offer is active" context={ <> Your two months free is now active until{' '} {nextPaymentDetails?.nextPaymentDateValue}. If you have any questions, feel free to{' '} { <Link to="/help-centre#contact-options" css={css` text-decoration: underline; color: ${palette.brand[500]}; `} > contact our support team </Link> } . </> } /> )} {canBeInPausePeriod && isInOfferOrPausePeriod && ( <SuccessSummary message="You have paused your support" context={ <> Your support is now paused until{' '} {nextPaymentDetails?.nextPaymentDateValue}. If you have any questions, feel free to{' '} { <Link to="/help-centre#contact-options" css={css` text-decoration: underline; color: ${palette.brand[500]}; `} > contact our support team </Link> } . </> } /> )} <Card> <Card.Header backgroundColor={cardConfig.colour} minHeightOverride="auto" > <h3 css={productCardTitleCss(cardConfig.invertText)}> {productTitle} </h3> {isGifted && ( <Ribbon copy="Gift" ribbonColour={giftRibbonColour} copyColour={giftRibbonCopyColour} icon={ <SvgGift isAnnouncedByScreenReader size="small" theme={{ fill: giftRibbonCopyColour }} /> } additionalCss={giftRibbonCss} /> )} </Card.Header> {cardConfig.showBenefitsSection && nextPaymentDetails && ( <Card.Section backgroundColor="#edf5fA"> <p css={benefitsTextCss}> You’re supporting the Guardian with{' '} {nextPaymentDetails.paymentValue} per{' '} {nextPaymentDetails.paymentInterval}, and have access to exclusive extras. </p> <BenefitsToggle productType={specificProductType.productType} subscriptionPlan={mainPlan} /> </Card.Section> )} {specificProductType.productType === 'guardianadlite' && nextPaymentDetails && ( <Card.Section backgroundColor="#edf5fA"> <p css={benefitsTextCss}> You’re subscribed to{' '} {specificProductType.productTitle()} and pay{' '} {nextPaymentDetails.paymentValueShort} a{' '} {nextPaymentDetails.paymentInterval} for non-personalised advertising. </p> </Card.Section> )} <Card.Section> <div css={productDetailLayoutCss}> <div> <h4 css={sectionHeadingCss}>Billing and payment</h4> <dl css={keyValueCss}> <div> <dt> {groupedProductType.showSupporterId ? 'Supporter ID' : 'Subscription ID'} </dt> <dd data-qm-masking="blocklist"> { productDetail.subscription .subscriptionId } </dd> </div> {groupedProductType.tierLabel && ( <div> <dt>{groupedProductType.tierLabel}</dt> <dd>{productDetail.tier}</dd> </div> )} {subscriptionStartDate && shouldShowStartDate && ( <div> <dt>Start date</dt> <dd> {parseDate( subscriptionStartDate, ).dateStr()} </dd> </div> )} {shouldShowJoinDateNotStartDate && ( <div> <dt>Join date</dt> <dd> {parseDate( productDetail.joinDate, ).dateStr()} </dd> </div> )} {userIsGifter && giftPurchaseDate && ( <div> <dt>Purchase date</dt> <dd> {parseDate( giftPurchaseDate, ).dateStr()} </dd> </div> )} {isGifted && !userIsGifter && ( <div> <dt>End date</dt> <dd> {parseDate( subscriptionEndDate, ).dateStr()} </dd> </div> )} {specificProductType.showTrialRemainingIfApplicable && productDetail.subscription.trialLength > 0 && !isGifted && productDetail.subscription.readerType !== 'Patron' && ( <div> <dt>Trial remaining</dt> <dd> { productDetail.subscription .trialLength }{' '} {productDetail.subscription .trialLength !== 1 ? 'days' : 'day'} </dd> </div> )} {nextPaymentDetails && productDetail.subscription.autoRenew && !hasCancellationPending && ( <div> <dt> {nextPaymentDetails.paymentKey} </dt> <dd> {nextPaymentDetails.isNewPaymentValue && ( <NewPriceAlert /> )} { nextPaymentDetails.paymentValue } {nextPaymentDetails.nextPaymentDateValue && productDetail.subscription .readerType !== 'Patron' && ` on ${nextPaymentDetails.nextPaymentDateValue}`} </dd> </div> )} </dl> </div> <div css={wideButtonLayoutCss}> {!isGifted && ( <Button aria-label={`${specificProductType.productTitle( mainPlan, )} : Manage ${ groupedProductType.friendlyName }`} data-cy={`Manage ${groupedProductType.friendlyName}`} size="small" cssOverrides={css` justify-content: center; `} onClick={() => { trackEvent({ eventCategory: 'account_overview', eventAction: 'click', eventLabel: `manage_${specificProductType.urlPart}`, }); navigate( `/${specificProductType.urlPart}`, { state: { productDetail: productDetail, }, }, ); }} > {`Manage ${groupedProductType.friendlyName}`} </Button> )} {showSwitchButton && ( <Button theme={themeButtonReaderRevenueBrand} size="small" cssOverrides={css` justify-content: center; `} onClick={() => navigate(`/switch`, { state: { productDetail: productDetail, user: user, }, }) } > Change to all-access digital </Button> )} </div> </div> </Card.Section> {entitledToEvents && ( <Card.Section> <div> <h4 css={sectionHeadingCss}> Guardian Live - Ticket Tailor promo codes </h4> <div> <dl css={keyValueCss}> <dt>{window.atob('TFBQRlJFRTZHTFRY')}</dt> <dd> gives you 6 free tickets each year (1 per event) </dd> </dl> </div> <div> <dl css={keyValueCss}> <dt>{window.atob('TFBQMjAyR0xUWA==')}</dt> <dd> gives you 20% off an extra 2 tickets per event </dd> </dl> </div> </div> </Card.Section> )} {productDetail.isPaidTier && ( <Card.Section> <div css={productDetailLayoutCss}> <div> <h4 css={sectionHeadingCss}>Payment method</h4> <PaymentMethod subscription={productDetail.subscription} inPaymentFailure={hasPaymentFailure} /> </div> {!isGifted && isSafeToUpdatePaymentMethod && ( <div css={wideButtonLayoutCss}> <Button aria-label={`${specificProductType.productTitle( mainPlan, )} : Update payment method`} size="small" cssOverrides={css` justify-content: center; `} priority="primary" icon={ hasPaymentFailure ? ( <ErrorIcon fill={palette.neutral[100]} /> ) : undefined } onClick={() => { trackEvent({ eventCategory: 'account_overview', eventAction: 'click', eventLabel: 'manage_payment_method', }); navigate( `/payment/${specificProductType.urlPart}`, { state: { productDetail }, }, ); }} > Update payment method </Button> </div> )} </div> </Card.Section> )} {!productDetail.isPaidTier && ( <Card.Section> <h4 css={sectionHeadingCss}>Payment</h4> <p css={css` ${textSans17}; margin: 0; `} > {isGifted ? 'Gift redemption' : 'Free'} </p> </Card.Section> )} {productDetail.billingCountry === 'United States' && !hasCancellationPending && ( <Card.Section> <div css={productDetailLayoutCss}> <div> <h4 css={sectionHeadingCss}> Cancel {groupedProductType.friendlyName} </h4> <p css={css` max-width: 350px; `} > Stop your recurring payment, at the end of current billing period. </p> </div> <div css={wideButtonLayoutCss}> <Button aria-label={`Cancel ${specificProductType.productTitle( mainPlan, )}`} size="small" cssOverrides={css` justify-content: center; `} priority="primary" onClick={() => { trackEvent({ eventCategory: 'account_overview', eventAction: 'click', eventLabel: 'cancel_product', }); navigate( `/cancel/${specificProductType.urlPart}`, { state: { productDetail }, }, ); }} > Cancel {groupedProductType.friendlyName} </Button> </div> </div> </Card.Section> )} </Card> </Stack> ); };