client/components/mma/accountoverview/ManageProduct.tsx (428 lines of code) (raw):
import { css } from '@emotion/react';
import {
headlineBold28,
palette,
space,
textSans17,
until,
} from '@guardian/source/foundations';
import { useState } from 'react';
import { Link, Navigate, useLocation } from 'react-router-dom';
import { featureSwitches } from '@/shared/featureSwitches';
import { cancellationFormatDate } from '../../../../shared/dates';
import type {
MembersDataApiResponse,
ProductDetail,
} from '../../../../shared/productResponse';
import {
getMainPlan,
isGift,
isPaidSubscriptionPlan,
isProduct,
} from '../../../../shared/productResponse';
import type {
ProductType,
WithProductType,
} from '../../../../shared/productTypes';
import { GROUPED_PRODUCT_TYPES } from '../../../../shared/productTypes';
import {
LoadingState,
useAsyncLoader,
} from '../../../utilities/hooks/useAsyncLoader';
import {
createProductDetailFetcher,
hasDeliveryRecordsFlow,
isNonServiceableCountry,
shouldHaveHolidayStopsFlow,
} from '../../../utilities/productUtils';
import { CallCentreEmailAndNumbers } from '../../shared/CallCenterEmailAndNumbers';
import { GenericErrorScreen } from '../../shared/GenericErrorScreen';
import { NAV_LINKS } from '../../shared/nav/NavConfig';
import { SupportTheGuardianButton } from '../../shared/SupportTheGuardianButton';
import { DeliveryAddressDisplay } from '../delivery/address/DeliveryAddressDisplay';
import { PageContainer } from '../Page';
import { ErrorIcon } from '../shared/assets/ErrorIcon';
import { GiftIcon } from '../shared/assets/GiftIcon';
import { JsonResponseHandler } from '../shared/asyncComponents/DefaultApiResponseHandler';
import { DefaultLoadingView } from '../shared/asyncComponents/DefaultLoadingView';
import { BasicProductInfoTable } from '../shared/BasicProductInfoTable';
import { LinkButton } from '../shared/Buttons';
import { getNextPaymentDetails } from '../shared/NextPaymentDetails';
import { PaymentDetailsTable } from '../shared/PaymentDetailsTable';
import { PaymentFailureAlertIfApplicable } from '../shared/PaymentFailureAlertIfApplicable';
import { ProductDescriptionListTable } from '../shared/ProductDescriptionListTable';
import { NewsletterOptinSection } from './NewsletterOptinSection';
import { SixForSixExplainerIfApplicable } from './SixForSixExplainer';
import { UpdateAmount } from './updateAmount/UpdateAmount';
const subHeadingTitleCss = `
${headlineBold28};
${until.tablet} {
font-size: 1.25rem;
line-height: 1.6;
};
`;
const subHeadingBorderTopCss = `
border-top: 1px solid ${palette.neutral['86']};
margin: ${space[10]}px 0 ${space[5]}px;
`;
export const subHeadingCss = `
${subHeadingBorderTopCss}
${subHeadingTitleCss}
`;
interface InnerContentProps {
manageProductProps: WithProductType<ProductType>;
productDetail: ProductDetail;
}
const InnerContent = ({
manageProductProps,
productDetail,
}: InnerContentProps) => {
const mainPlan = getMainPlan(productDetail.subscription);
if (!mainPlan) {
throw new Error('mainPlan does not exist in manageProduct page');
}
const specificProductType = manageProductProps.productType;
const groupedProductType =
GROUPED_PRODUCT_TYPES[specificProductType.groupedProductType];
const hasCancellationPending = productDetail.subscription.cancelledAt;
const cancelledCopy =
specificProductType.cancelledCopy || groupedProductType.cancelledCopy;
const [overiddenAmount, setOveriddenAmount] = useState<number | null>(null);
const isAmountOveridable = specificProductType.updateAmountMdaEndpoint;
const nextPaymentDetails = getNextPaymentDetails(
mainPlan,
productDetail.subscription,
overiddenAmount,
!!productDetail.alertText,
);
const maybePatronSuffix =
productDetail.subscription.readerType === 'Patron' ? ' - Patron' : '';
const showSupporterPlusUpdateAmount =
specificProductType.productType === 'supporterplus' &&
featureSwitches.supporterPlusUpdateAmount &&
!hasCancellationPending;
return (
<>
<PaymentFailureAlertIfApplicable productDetails={[productDetail]} />
<div
css={css`
${subHeadingBorderTopCss}
display: flex;
align-items: start;
justify-content: space-between;
`}
>
<h2
css={css`
${subHeadingTitleCss}
margin: 0;
`}
>
{specificProductType.productTitle(mainPlan)}
{maybePatronSuffix}
</h2>
{isGift(productDetail.subscription) && (
<i
css={css`
margin: 4px 0 0 ${space[3]}px;
`}
>
<GiftIcon alignArrowToThisSide={'left'} />
</i>
)}
</div>
{hasCancellationPending && (
<p
css={css`
${textSans17};
`}
>
<ErrorIcon fill={palette.brandAlt[200]} />
<span
css={css`
margin-left: ${space[2]}px;
`}
>
{cancelledCopy}{' '}
<strong>
{cancellationFormatDate(
productDetail.subscription
.cancellationEffectiveDate,
)}
</strong>
</span>
.
</p>
)}
{(isAmountOveridable || showSupporterPlusUpdateAmount) &&
!isNonServiceableCountry(productDetail) &&
isPaidSubscriptionPlan(mainPlan) ? (
<UpdateAmount
subscriptionId={productDetail.subscription.subscriptionId}
mainPlan={mainPlan}
productType={specificProductType}
nextPaymentDate={productDetail.subscription.nextPaymentDate}
amountUpdateStateChange={setOveriddenAmount}
isTestUser={productDetail.isTestUser}
/>
) : (
<BasicProductInfoTable
groupedProductType={groupedProductType}
productDetail={productDetail}
/>
)}
<h2
css={css`
${subHeadingCss}
`}
>
Payment
</h2>
<SixForSixExplainerIfApplicable
additionalCss={css`
${textSans17};
`}
mainPlan={mainPlan}
hasCancellationPending={hasCancellationPending}
/>
<PaymentDetailsTable
productDetail={productDetail}
nextPaymentDetails={nextPaymentDetails}
hasCancellationPending={hasCancellationPending}
/>
{productDetail.isPaidTier &&
productDetail.subscription.safeToUpdatePaymentMethod &&
!productDetail.subscription.payPalEmail && (
<LinkButton
colour={
productDetail.alertText
? palette.brand[400]
: palette.brand[800]
}
textColour={
productDetail.alertText
? palette.neutral[100]
: palette.brand[400]
}
fontWeight={'bold'}
alert={!!productDetail.alertText}
text="Update payment method"
to={`/payment/${specificProductType.urlPart}`}
state={{ productDetail: productDetail }}
/>
)}
{specificProductType.delivery?.showAddress?.(
productDetail.subscription,
) &&
productDetail.subscription.deliveryAddress && (
<>
<h2
css={css`
${subHeadingCss}
`}
>
Delivery address
</h2>
<ProductDescriptionListTable
alternateRowBgColors
borderColour={palette.neutral[86]}
content={[
{
title: 'Address',
value: (
<DeliveryAddressDisplay
{...productDetail.subscription
.deliveryAddress}
/>
),
spanTwoCols: true,
},
...(specificProductType.delivery
?.enableDeliveryInstructionsUpdate
? [
{
title: 'Instructions',
value: productDetail
.subscription
.deliveryAddress
.instructions,
spanTwoCols: true,
},
]
: []),
]}
/>
<LinkButton
colour={palette.brand[800]}
textColour={palette.brand[400]}
fontWeight="bold"
text="Manage delivery address"
to={`/delivery/${specificProductType.urlPart}/address`}
state={productDetail}
/>
</>
)}
{hasDeliveryRecordsFlow(specificProductType) && (
<>
<h2
css={css`
${subHeadingCss}
`}
>
Delivery history
</h2>
<p
css={css`
${textSans17};
`}
>
Check delivery history and report an issue.
</p>
<LinkButton
colour={palette.brand[800]}
textColour={palette.brand[400]}
fontWeight="bold"
text="Manage delivery history"
to={`/delivery/${specificProductType.urlPart}/records`}
state={{ productDetail }}
/>
</>
)}
{shouldHaveHolidayStopsFlow(specificProductType) &&
productDetail.subscription.autoRenew &&
!hasCancellationPending && (
<>
<h2
css={css`
${subHeadingCss}
`}
>
Going on holiday?
</h2>
<p
css={css`
${textSans17};
`}
>
Don’t fret - you can manage your suspensions by
clicking the button below. You will be credited for
each suspended{' '}
{specificProductType.holidayStops.issueKeyword} on
the first bill after the suspension date.
</p>
<LinkButton
colour={palette.brand[800]}
textColour={palette.brand[400]}
fontWeight="bold"
text="Manage suspensions"
to={`/suspend/${specificProductType.urlPart}`}
state={{ productDetail }}
/>
</>
)}
{!productDetail.subscription.autoRenew &&
specificProductType.renewalMetadata && (
<>
<h2
css={css`
${subHeadingCss}
`}
>
Renewal
</h2>
<p
css={css`
${textSans17};
`}
>
To renew this one-off{' '}
{specificProductType.friendlyName}, please contact
us.
</p>
<CallCentreEmailAndNumbers />
<p
css={css`
${textSans17};
`}
>
Alternatively, if you would prefer to start a
recurring {specificProductType.friendlyName} you can
explore payment options and subscribe online by
clicking the button below.
</p>
<SupportTheGuardianButton
{...specificProductType.renewalMetadata}
size="small"
/>
</>
)}
{specificProductType.productPageNewsletterIDs && (
<NewsletterOptinSection
activeNewletterIDs={
specificProductType.productPageNewsletterIDs
}
/>
)}
{!hasCancellationPending &&
productDetail.billingCountry !== 'United States' && (
<CancellationCTA
productDetail={productDetail}
friendlyName={groupedProductType.friendlyName}
specificProductType={specificProductType}
/>
)}
</>
);
};
interface CancellationCTAProps {
productDetail: ProductDetail;
friendlyName: string;
specificProductType: ProductType;
}
const CancellationCTA = (props: CancellationCTAProps) => {
const shouldContactUsToCancel =
!props.productDetail.selfServiceCancellation.isAllowed ||
!props.specificProductType.cancellation;
return (
<div
css={css`
margin: ${space[24]}px 0 0 auto;
${textSans17};
color: ${palette.neutral[46]};
`}
>
{shouldContactUsToCancel &&
`Would you like to cancel your ${props.friendlyName}? `}
<Link
css={css`
color: ${palette.brand['500']};
`}
to={'/cancel/' + props.specificProductType.urlPart}
state={{ productDetail: props.productDetail }}
>
{shouldContactUsToCancel
? 'Contact us'
: `Cancel ${props.friendlyName}`}
</Link>
</div>
);
};
interface ManageProductRouterState {
productDetail: ProductDetail;
}
const AsyncLoadedInnerContent = (props: WithProductType<ProductType>) => {
const request = createProductDetailFetcher(
props.productType.allProductsProductTypeFilterString,
);
const { data, loadingState } = useAsyncLoader<MembersDataApiResponse>(
request,
JsonResponseHandler,
);
if (loadingState == LoadingState.HasError) {
return <GenericErrorScreen />;
}
if (loadingState == LoadingState.IsLoading) {
return <DefaultLoadingView loadingMessage="Loading your product..." />;
}
if (data == null || data.products.length == 0) {
return <Navigate to="/" />;
}
const productDetail = data.products.filter(isProduct)[0];
return (
<InnerContent
manageProductProps={props}
productDetail={productDetail}
/>
);
};
export const ManageProduct = (props: WithProductType<ProductType>) => {
const location = useLocation();
const routerState = location.state as ManageProductRouterState;
const productDetail = routerState?.productDetail;
return (
<PageContainer
selectedNavItem={NAV_LINKS.accountOverview}
pageTitle={`Manage ${
GROUPED_PRODUCT_TYPES[props.productType.groupedProductType]
.shortFriendlyName ||
GROUPED_PRODUCT_TYPES[props.productType.groupedProductType]
.friendlyName
}`}
minimalFooter
>
{productDetail ? (
<InnerContent
manageProductProps={props}
productDetail={productDetail}
/>
) : (
<AsyncLoadedInnerContent {...props} />
)}
</PageContainer>
);
};