client/components/mma/accountoverview/manageProducts/ManageProductV2.tsx (316 lines of code) (raw):
import { css } from '@emotion/react';
import {
headlineBold20,
headlineBold34,
palette,
space,
textSans17,
textSansBold17,
until,
} from '@guardian/source/foundations';
import {
Button,
LinkButton,
Stack,
SvgCalendar,
SvgClock,
SvgCreditCard,
} from '@guardian/source/react-components';
import { useLocation, useNavigate } from 'react-router-dom';
import { PageContainer } from '@/client/components/mma/Page';
import { ErrorIcon } from '@/client/components/mma/shared/assets/ErrorIcon';
import { JsonResponseHandler } from '@/client/components/mma/shared/asyncComponents/DefaultApiResponseHandler';
import { DefaultLoadingView } from '@/client/components/mma/shared/asyncComponents/DefaultLoadingView';
import { getNextPaymentDetails } from '@/client/components/mma/shared/NextPaymentDetails';
import { PaymentDetails } from '@/client/components/mma/shared/PaymentDetails';
import { PaymentFailureAlertIfApplicable } from '@/client/components/mma/shared/PaymentFailureAlertIfApplicable';
import { ProductInfoTableV2 } from '@/client/components/mma/shared/ProductInfoTableV2';
import { GenericErrorScreen } from '@/client/components/shared/GenericErrorScreen';
import { NAV_LINKS } from '@/client/components/shared/nav/NavConfig';
import {
buttonCentredCss,
stackedButtonLayoutCss,
} from '@/client/styles/ButtonStyles';
import {
iconListCss,
listWithDividersCss,
whatHappensNextIconCss,
} from '@/client/styles/GenericStyles';
import {
LoadingState,
useAsyncLoader,
} from '@/client/utilities/hooks/useAsyncLoader';
import { createProductDetailFetcher } from '@/client/utilities/productUtils';
import { cancellationFormatDate } from '@/shared/dates';
import type {
MembersDataApiResponse,
ProductDetail,
} from '@/shared/productResponse';
import { getMainPlan, isProduct } from '@/shared/productResponse';
import type { ProductType, WithProductType } from '@/shared/productTypes';
import {
getBillingPeriodAdjective,
GROUPED_PRODUCT_TYPES,
} from '@/shared/productTypes';
const subHeadingTitleCss = `
${headlineBold34};
${until.tablet} {
${headlineBold20};
};
`;
const subHeadingBorderTopCss = `
margin: 16px 0 16px;
`;
interface InnerContentProps {
manageProductV2Props: WithProductType<ProductType>;
productDetail: ProductDetail;
}
const InnerContent = ({
manageProductV2Props,
productDetail,
}: InnerContentProps) => {
const navigate = useNavigate();
const mainPlan = getMainPlan(productDetail.subscription);
if (!mainPlan) {
throw new Error('mainPlan does not exist in manageProductV2 page');
}
const nextPaymentDetails = getNextPaymentDetails(
mainPlan,
productDetail.subscription,
null,
!!productDetail.alertText,
);
const specificProductType = manageProductV2Props.productType;
const groupedProductType =
GROUPED_PRODUCT_TYPES[specificProductType.groupedProductType];
const hasCancellationPending = productDetail.subscription.cancelledAt;
const isSelfServeCancellationAllowed =
productDetail.selfServiceCancellation.isAllowed;
const cancelledCopy =
specificProductType.cancelledCopy || groupedProductType.cancelledCopy;
const monthlyOrAnnual = getBillingPeriodAdjective(
nextPaymentDetails?.paymentInterval,
).toLowerCase();
return (
<>
<PaymentFailureAlertIfApplicable productDetails={[productDetail]} />
<div
css={css`
${subHeadingBorderTopCss};
display: flex;
align-items: start;
justify-content: space-between;
`}
>
<h2
css={css`
${subHeadingTitleCss};
margin: 0;
`}
>
Manage{' '}
{specificProductType.productTitle(mainPlan).toLowerCase()}
</h2>
</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>
)}
<ProductInfoTableV2 productDetail={productDetail} />
<h3
css={css`
${textSansBold17};
`}
>
Payment
</h3>
<section
css={css`
border-bottom: 1px solid ${palette.neutral[86]};
padding-bottom: ${space[5]}px;
`}
>
<Stack space={5}>
<div
css={css`
border-top: 1px solid ${palette.neutral[86]};
padding-bottom: ${space[1]}px;
`}
></div>
<ul
css={[
iconListCss,
listWithDividersCss,
whatHappensNextIconCss,
]}
>
<li>
<SvgClock size="medium" />
<span>
<>
<strong
css={css`
padding-bottom: ${space[1]}px;
`}
>
Next {monthlyOrAnnual}
{''} payment
</strong>
<br /> {nextPaymentDetails?.paymentValue}
</>
</span>
</li>
<li>
<SvgCalendar size="medium" />
<span>
<>
<strong
css={css`
padding-bottom: ${space[1]}px;
`}
>
Next payment date
</strong>{' '}
<br />
{nextPaymentDetails?.nextPaymentDateValue}
</>
</span>
</li>
<li>
<SvgCreditCard size="medium" />
<span data-qm-masking="blocklist">
<strong
css={css`
padding-bottom: ${space[1]}px;
`}
>
Payment Method
</strong>
<br />
<PaymentDetails
subscription={productDetail.subscription}
/>
</span>
</li>
</ul>
</Stack>
</section>
<section
css={css`
margin-top: ${space[4]}px;
`}
>
<div css={stackedButtonLayoutCss}>
{productDetail.isPaidTier &&
productDetail.subscription.safeToUpdatePaymentMethod &&
!productDetail.subscription.payPalEmail && (
<LinkButton
href={`/payment/${specificProductType.urlPart}`}
cssOverrides={buttonCentredCss}
>
Update payment method
</LinkButton>
)}
{!hasCancellationPending &&
isSelfServeCancellationAllowed &&
productDetail.billingCountry !== 'United States' && (
<Button
priority="subdued"
onClick={() => {
navigate(
'/cancel/' +
specificProductType.urlPart,
{
state: {
productDetail: productDetail,
},
},
);
}}
cssOverrides={css`
margin-left: ${space[5]}px;
`}
>
Cancel {groupedProductType.friendlyName}
</Button>
)}
</div>
</section>
</>
);
};
interface ManageProductV2RouterState {
productDetail: ProductDetail;
}
const AsyncLoadedInnerContent = (props: WithProductType<ProductType>) => {
const navigate = useNavigate();
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) {
navigate('/');
return null;
}
const productDetail = data.products.filter(isProduct)[0];
return (
<InnerContent
manageProductV2Props={props}
productDetail={productDetail}
/>
);
};
export const ManageProductV2 = (props: WithProductType<ProductType>) => {
const location = useLocation();
const routerState = location.state as ManageProductV2RouterState;
const productDetail = routerState?.productDetail;
return (
<PageContainer
selectedNavItem={NAV_LINKS.accountOverview}
pageTitle={`Your ${
GROUPED_PRODUCT_TYPES[props.productType.groupedProductType]
.shortFriendlyName ||
GROUPED_PRODUCT_TYPES[props.productType.groupedProductType]
.friendlyName
}`}
minimalFooter
>
{productDetail ? (
<InnerContent
manageProductV2Props={props}
productDetail={productDetail}
/>
) : (
<AsyncLoadedInnerContent {...props} />
)}
</PageContainer>
);
};