Skip to content

Commit

Permalink
feat(auth): implement cancel subscription flow
Browse files Browse the repository at this point in the history
  • Loading branch information
ChristiaanScheermeijer committed Jul 30, 2021
1 parent bc9dae1 commit 2fef77f
Show file tree
Hide file tree
Showing 19 changed files with 326 additions and 27 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
@use '../../styles/variables';
@use '../../styles/theme';

.title {
margin-bottom: 8px;
font-family: theme.$body-font-family;
font-weight: 700;
font-size: 24px;
}

.paragraph {
font-family: theme.$body-font-family;
}

.confirmButton {
margin-bottom: 8px;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import React from 'react';
import { render } from '@testing-library/react';

import CancelSubscriptionForm from './CancelSubscriptionForm';

describe('<CancelSubscriptionForm>', () => {
test('renders and matches snapshot', () => {
const { container } = render(<CancelSubscriptionForm error={null} onCancel={jest.fn()} onConfirm={jest.fn()} />);

expect(container).toMatchSnapshot();
});
});
31 changes: 31 additions & 0 deletions src/components/CancelSubscriptionForm/CancelSubscriptionForm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import React from 'react';
import { useTranslation } from 'react-i18next';

import Button from '../Button/Button';
import FormFeedback from '../FormFeedback/FormFeedback';

import styles from './CancelSubscriptionForm.module.scss';

type Props = {
onConfirm: () => void;
onCancel: () => void;
error: string | null;
};

const CancelSubscriptionForm: React.FC<Props> = ({ onConfirm, onCancel, error }: Props) => {
const { t } = useTranslation('account');

return (
<div>
{error ? <FormFeedback variant="error">{error}</FormFeedback> : null}
<h2 className={styles.title}>{t('cancel_subscription.title')}</h2>
<p className={styles.paragraph}>
{t('cancel_subscription.explanation')}
</p>
<Button className={styles.confirmButton} label="Unsubscribe" color="primary" variant="contained" onClick={onConfirm} fullWidth />
<Button label="No, thanks" variant="outlined" onClick={onCancel} fullWidth />
</div>
);
};

export default CancelSubscriptionForm;
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`<CancelSubscriptionForm> renders and matches snapshot 1`] = `
<div>
<div>
<h2
class="title"
>
cancel_subscription.title
</h2>
<p
class="paragraph"
>
cancel_subscription.explanation
</p>
<button
class="button confirmButton primary fullWidth"
>
<span>
Unsubscribe
</span>
</button>
<button
class="button default outlined fullWidth"
>
<span>
No, thanks
</span>
</button>
</div>
</div>
`;
30 changes: 20 additions & 10 deletions src/components/Payment/Payment.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,12 @@ type Props = {
panelClassName?: string;
panelHeaderClassName?: string;
onCompleteSubscriptionClick?: () => void;
onCancelSubscriptionClick?: () => void;
};

const Payment = ({
onCompleteSubscriptionClick,
onCancelSubscriptionClick,
activePaymentDetail,
activeSubscription,
transactions,
Expand All @@ -40,16 +42,23 @@ const Payment = ({
<h3>{t('user:payment.subscription_details')}</h3>
</div>
{activeSubscription ? (
<div className={styles.infoBox} key={activeSubscription.subscriptionId}>
<p>
<strong>{t('user:payment.monthly_subscription')}</strong> <br />
{t('user:payment.next_billing_date_on')} {formatDate(activeSubscription.expiresAt)}
</p>
<p className={styles.price}>
<strong>{formatPrice(activeSubscription.totalPrice, activeSubscription.nextPaymentCurrency, customer.country)}</strong>
<small>/{t(`account:periods.${activeSubscription.period}`)}</small>
</p>
</div>
<React.Fragment>
<div className={styles.infoBox} key={activeSubscription.subscriptionId}>
<p>
<strong>{t('user:payment.monthly_subscription')}</strong> <br />
{activeSubscription.status === 'active'
? t('user:payment.next_billing_date_on', { date: formatDate(activeSubscription.expiresAt) })
: t('user:payment.subscription_expires_on', { date: formatDate(activeSubscription.expiresAt) })}
</p>
<p className={styles.price}>
<strong>{formatPrice(activeSubscription.totalPrice, activeSubscription.nextPaymentCurrency, customer.country)}</strong>
<small>/{t(`account:periods.${activeSubscription.period}`)}</small>
</p>
</div>
{activeSubscription.status === 'active' ? (
<Button label={t('user:payment.cancel_subscription')} onClick={onCancelSubscriptionClick} />
) : null}
</React.Fragment>
) : (
<React.Fragment>
<p>{t('user:payment.no_subscription')}</p>
Expand Down Expand Up @@ -104,4 +113,5 @@ const Payment = ({
</>
);
};

export default Payment;
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
@use '../../styles/variables';
@use '../../styles/theme';

.title {
margin-bottom: 8px;
font-family: theme.$body-font-family;
font-weight: 700;
font-size: 24px;
}

.paragraph {
font-family: theme.$body-font-family;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import React from 'react';
import { render } from '@testing-library/react';

import SubscriptionCancelled from './SubscriptionCancelled';

describe('<SubscriptionCancelled>', () => {
test('renders and matches snapshot', () => {
const { container } = render(<SubscriptionCancelled onClose={jest.fn()} expiresDate="12/12/2021" />);

expect(container).toMatchSnapshot();
});
});
27 changes: 27 additions & 0 deletions src/components/SubscriptionCancelled/SubscriptionCancelled.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import React from 'react';
import { useTranslation } from 'react-i18next';

import Button from '../Button/Button';

import styles from './SubscriptionCancelled.module.scss';

type Props = {
expiresDate: string;
onClose: () => void;
};

const SubscriptionCancelled: React.FC<Props> = ({ expiresDate, onClose }: Props) => {
const { t } = useTranslation('account');

return (
<div className={styles.SubscriptionCancelled}>
<h2 className={styles.title}>{t('subscription_cancelled.title')}</h2>
<p className={styles.paragraph}>
{t('subscription_cancelled.message', { date: expiresDate})}
</p>
<Button label="Back to profile" variant="outlined" onClick={onClose} fullWidth />
</div>
);
};

export default SubscriptionCancelled;
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`<SubscriptionCancelled> renders and matches snapshot 1`] = `
<div>
<div>
<h2
class="title"
>
subscription_cancelled.title
</h2>
<p
class="paragraph"
>
subscription_cancelled.message
</p>
<button
class="button default outlined fullWidth"
>
<span>
Back to profile
</span>
</button>
</div>
</div>
`;
10 changes: 7 additions & 3 deletions src/containers/AccountModal/AccountModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import PersonalDetails from './forms/PersonalDetails';
import ChooseOffer from './forms/ChooseOffer';
import Checkout from './forms/Checkout';
import ResetPassword from './forms/ResetPassword';
import CancelSubscription from './forms/CancelSubscription';

const PUBLIC_VIEWS = ['login', 'create-account', 'forgot-password', 'reset-password'];

Expand All @@ -26,17 +27,18 @@ const AccountModal = () => {
const [view, setView] = useState(viewParam);
const message = useQueryParam('message');
const { loading, auth } = AccountStore.useState((s) => s);
const isPublicView = viewParam && !PUBLIC_VIEWS.includes(viewParam);

useEffect(() => {
// make sure the last view is rendered even when the modal gets closed
if (viewParam) setView(viewParam);
}, [viewParam]);

useEffect(() => {
if (!!viewParam && !loading && !auth && !PUBLIC_VIEWS.includes(viewParam)) {
if (!!viewParam && !loading && !auth && !isPublicView) {
history.push(addQueryParam(history, 'u', 'login'));
}
}, [viewParam, history, loading, auth]);
}, [viewParam, history, loading, auth, isPublicView]);

const {
assets: { banner },
Expand All @@ -47,7 +49,7 @@ const AccountModal = () => {
};

const renderForm = () => {
if (!auth && loading) {
if (!auth && loading && !isPublicView) {
return (
<div style={{ height: 300 }}>
<LoadingOverlay inline />
Expand All @@ -74,6 +76,8 @@ const AccountModal = () => {
return <PaymentFailed type="cancelled" onCloseButtonClick={closeHandler} />;
case 'welcome':
return <Welcome onCloseButtonClick={closeHandler} onCountdownCompleted={closeHandler} />;
case 'unsubscribe':
return <CancelSubscription />;
}
};

Expand Down
51 changes: 51 additions & 0 deletions src/containers/AccountModal/forms/CancelSubscription.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useHistory } from 'react-router';

import CancelSubscriptionForm from '../../../components/CancelSubscriptionForm/CancelSubscriptionForm';
import { removeQueryParam } from '../../../utils/history';
import LoadingOverlay from '../../../components/LoadingOverlay/LoadingOverlay';
import { AccountStore, cancelSubscription } from '../../../stores/AccountStore';
import SubscriptionCancelled from '../../../components/SubscriptionCancelled/SubscriptionCancelled';
import { formatDate } from '../../../utils/formatting';

const CancelSubscription = () => {
const { t } = useTranslation('account');
const history = useHistory();
const subscription = AccountStore.useState((s) => s.subscription);
const [cancelled, setCancelled] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);

const cancelSubscriptionConfirmHandler = async () => {
setLoading(true);
setError(null);

try {
await cancelSubscription();
setCancelled(true);
} catch (error: unknown) {
setError(t('cancel_subscription.unknown_error_occurred'));
}

setLoading(false);
};

const closeHandler = () => {
history.replace(removeQueryParam(history, 'u'));
};

if (!subscription) return null;

return (
<React.Fragment>
{cancelled ? (
<SubscriptionCancelled expiresDate={formatDate(subscription.expiresAt)} onClose={closeHandler} />
) : (
<CancelSubscriptionForm onConfirm={cancelSubscriptionConfirmHandler} onCancel={closeHandler} error={error} />
)}
{loading ? <LoadingOverlay inline /> : null}
</React.Fragment>
);
};
export default CancelSubscription;
16 changes: 4 additions & 12 deletions src/containers/Subscription/SubscriptionContainer.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { useMutation, useQuery } from 'react-query';
import type { PaymentDetail, Subscription, Transaction, UpdateSubscriptionPayload } from 'types/subscription';
import { useQuery } from 'react-query';
import type { PaymentDetail, Subscription, Transaction } from 'types/subscription';

import { getPaymentDetails, getSubscriptions, getTransactions, updateSubscription } from '../../services/subscription.service';
import { getPaymentDetails, getSubscriptions, getTransactions } from '../../services/subscription.service';
import { AccountStore } from '../../stores/AccountStore';
import { ConfigStore } from '../../stores/ConfigStore';

Expand Down Expand Up @@ -30,19 +30,12 @@ const SubscriptionContainer = ({ children }: Props): JSX.Element => {
const getSubscriptionsQuery = useQuery(['subscriptions', customerId], () => getSubscriptions({ customerId }, sandbox, jwt));
const { data: subscriptions, isLoading: isSubscriptionsLoading } = getSubscriptionsQuery;

const subscriptionMutation = useMutation((values: UpdateSubscriptionPayload) => updateSubscription(values, sandbox, jwt));
const { mutate: mutateSubscriptions, isLoading: isSubscriptionMutationLoading } = subscriptionMutation;

const getPaymentDetailsQuery = useQuery(['paymentDetails', customerId], () => getPaymentDetails({ customerId }, sandbox, jwt));
const { data: paymentDetails, isLoading: isPaymentDetailsLoading } = getPaymentDetailsQuery;

const getTransactionsQuery = useQuery(['transactions', customerId], () => getTransactions({ customerId }, sandbox, jwt));
const { data: transactions, isLoading: isTransactionsLoading } = getTransactionsQuery;

const onUpdateSubscriptionSubmit = ({ offerId, status }: Subscription, cancellationReason?: string) => {
mutateSubscriptions({ customerId, offerId, status, cancellationReason });
};

return children({
activeSubscription: subscriptions?.responseData.items.find(
(subscription) => subscription.status !== 'expired' && subscription.status !== 'terminated',
Expand All @@ -51,8 +44,7 @@ const SubscriptionContainer = ({ children }: Props): JSX.Element => {
subscriptions: subscriptions?.responseData.items,
paymentDetails: paymentDetails?.responseData.paymentDetails,
transactions: transactions?.responseData.items,
isLoading: isSubscriptionsLoading || isPaymentDetailsLoading || isTransactionsLoading || isSubscriptionMutationLoading,
onUpdateSubscriptionSubmit,
isLoading: isSubscriptionsLoading || isPaymentDetailsLoading || isTransactionsLoading,
} as ChildrenParams);
};

Expand Down
9 changes: 9 additions & 0 deletions src/i18n/locales/en_US/account.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
{
"cancel_subscription": {
"explanation": "You will be unsubscribed from your current plan by clicking the unsubscribe button below.",
"title": "We are sorry to see you go.",
"unknown_error_occurred": "An unknown error occurred, please try again later."
},
"checkout": {
"applicable_tax": "Applicable tax ({{taxRate}}%)",
"close": "Close",
Expand Down Expand Up @@ -101,5 +106,9 @@
"reset_password": "Edit Password",
"text": "If you want to edit your password, click 'YES, Reset' to receive password reset instruction on your mail",
"yes": "Yes, reset"
},
"subscription_cancelled": {
"message": "You have been successfully unsubscribed. Your current plan will expire on {{date}}",
"title": "Miss you already."
}
}
Loading

0 comments on commit 2fef77f

Please sign in to comment.