feat(payments): update subscription upgrade UX for new designs

- Reworked SubscriptionUpdate to match new UX mocks

- New PlanUpgradeDetails subcomponent for SubscriptionUpdate

- try to extract some more common CSS & formatting out of
  SubscriptionCreate to share with SubscriptionUpdate

- test tweaks

- l10n string updates

fixes #3931
This commit is contained in:
Les Orchard 2020-04-29 16:52:03 -04:00
Родитель 3dfdada573
Коммит cc0b1d69d9
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: 857F1A39AA1C4902
14 изменённых файлов: 386 добавлений и 202 удалений

Просмотреть файл

@ -131,8 +131,9 @@ sub-redirect-skip-survey = No thanks, just take me to my product.
default-input-error = This field is required
## subscription upgrade
product-plan-upgrade-heading = Review your upgrade
sub-update-failed = Plan update failed
sub-update-title = Billing Information
sub-update-title = Billing information
sub-update-card-ending = Card Ending { $last }
sub-update-card-exp = Expires { $cardExpMonth }/{ $cardExpYear }
sub-update-copy =
@ -161,9 +162,12 @@ sub-update-confirm-year = { $intervalCount ->
*[other] I authorize { -brand-name-mozilla }, maker of { -brand-name-firefox } products, to charge my payment method <strong>{ $amount } every { $intervalCount } years</strong>, according to payment terms, until I cancel my subscription.
}
sub-update-submit = Change Plans
sub-update-submit = Confirm upgrade
sub-update-indicator =
.aria-label = upgrade indicator
sub-update-current-plan-label = Current plan
sub-update-new-plan-label = New plan
sub-update-total-label = New total
## subscription upgrade plan details
## $amount (Number) - The amount billed. It will be formatted as currency.

Просмотреть файл

@ -69,10 +69,6 @@ form.payment {
margin-top: 32px;
}
.terms {
margin-top: 32px;
}
.lock::before {
background-image: url('./images/lock.svg');
background-position: 0 4px;

Просмотреть файл

@ -25,7 +25,11 @@ import {
useValidatorState,
} from '../../lib/validator';
import { useCallbackOnce } from '../../lib/hooks';
import { getLocalizedCurrency, formatPlanPricing } from '../../lib/formats';
import {
getLocalizedCurrency,
formatPlanPricing,
getDefaultPaymentConfirmText,
} from '../../lib/formats';
import { AppContext } from '../../lib/AppContext';
import './index.scss';
@ -61,22 +65,6 @@ export type PaymentFormProps = {
submitNonce: string;
};
function getDefaultPaymentConfirmText(
amount: number,
currency: string,
interval: PlanInterval,
intervalCount: number
): string {
const planPricing = formatPlanPricing(
amount,
currency,
interval,
intervalCount
);
return `I authorize Mozilla, maker of Firefox products, to charge my payment method <strong>${planPricing}</strong>, according to payment terms, until I cancel my subscription.`;
}
export const PaymentForm = ({
inProgress = false,
confirm = true,

Просмотреть файл

@ -7,5 +7,5 @@
}
.terms {
margin-top: 0;
margin-top: 32px;
}

Двоичные данные
packages/fxa-payments-server/src/images/down-arrow.png Normal file

Двоичный файл не отображается.

После

Ширина:  |  Высота:  |  Размер: 1.2 KiB

Просмотреть файл

@ -210,3 +210,33 @@ hr {
box-shadow: none;
}
}
.c-card {
position: relative;
}
.c-card::before {
content: '';
display: block;
height: 27px;
left: -45px;
position: absolute;
top: 0;
width: 40px;
}
.visa::before {
background: url('./images/visa.svg') no-repeat;
}
.mastercard::before {
background: url('./images/mastercard.svg') no-repeat;
}
.american::before {
background: url('./images/amex.svg') no-repeat;
}
.discover::before {
background: url('./images/discover.svg') no-repeat;
}

Просмотреть файл

@ -146,3 +146,19 @@ export function formatPlanPricing(
return `${formattedAmount} every ${intervalCount} years`;
}
}
export function getDefaultPaymentConfirmText(
amount: number,
currency: string,
interval: PlanInterval,
intervalCount: number
): string {
const planPricing = formatPlanPricing(
amount,
currency,
interval,
intervalCount
);
return `I authorize Mozilla, maker of Firefox products, to charge my payment method <strong>${planPricing}</strong>, according to payment terms, until I cancel my subscription.`;
}

Просмотреть файл

@ -0,0 +1,126 @@
import React from 'react';
import { Localized } from '@fluent/react';
import { getLocalizedCurrency, formatPlanPricing } from '../../../lib/formats';
import { metadataFromPlan } from '../../../store/utils';
import ffLogo from '../../../images/firefox-logo.svg';
import './index.scss';
import { Plan } from '../../../store/types';
export const PlanUpgradeDetails = ({
selectedPlan,
upgradeFromPlan,
isMobile,
className = 'default',
}: {
selectedPlan: Plan;
upgradeFromPlan: Plan;
isMobile: boolean;
className?: string;
}) => {
const totalPrice = formatPlanPricing(
selectedPlan.amount,
selectedPlan.currency,
selectedPlan.interval,
selectedPlan.interval_count
);
const role = isMobile ? undefined : 'complementary';
return (
<section
className={`plan-details-component plan-upgrade-details-component ${className}`}
{...{ role }}
data-testid="plan-upgrade-details-component"
>
<div className="plan-details-component-inner">
<p className="plan-label current-plan-label">
<Localized id="sub-update-current-plan-label">Current plan</Localized>
</p>
<PlanDetailsCard className="from-plan" plan={upgradeFromPlan} />
<p className="plan-label new-plan-label">
<Localized id="sub-update-new-plan-label">New plan</Localized>
</p>
<PlanDetailsCard className="to-plan" plan={selectedPlan} />
<div
className="plan-details-total"
aria-labelledby="plan-details-product"
>
<div className="plan-details-total-inner">
<Localized id="sub-update-total-label">
<p className="label">New total</p>
</Localized>
<Localized
id={`plan-price-${selectedPlan.interval}`}
$amount={getLocalizedCurrency(
selectedPlan.amount,
selectedPlan.currency
)}
$intervalCount={selectedPlan.interval_count}
>
<p className="total-price">{totalPrice}</p>
</Localized>
</div>
</div>
</div>
</section>
);
};
export const PlanDetailsCard = ({
plan,
className = '',
}: {
plan: Plan;
className?: string;
}) => {
const { product_name, amount, currency, interval, interval_count } = plan;
const { webIconURL } = metadataFromPlan(plan);
const planPrice = formatPlanPricing(
amount,
currency,
interval,
interval_count
);
return (
<div
className={`container card plan-details-component-card plan-upgrade-details-component-card ${className}`}
>
<div className="plan-details-header">
<div className="plan-details-header-wrap">
<div className="plan-details-logo-wrap">
<img
src={webIconURL || ffLogo}
alt={product_name}
data-testid="product-logo"
/>
</div>
<div className="plan-details-heading-wrap">
<h3
id="plan-details-product"
className="product-name plan-details-product"
>
{product_name}
</h3>
{/* TODO: make this configurable, issue #4741 / FXA-1484 */}
<p className="product-description plan-details-description">
Full-device VPN
</p>
</div>
</div>
<Localized
id={`plan-price-${interval}`}
$amount={getLocalizedCurrency(amount, currency)}
$intervalCount={interval_count}
>
<p>{planPrice}</p>
</Localized>
</div>
</div>
);
};
export default PlanUpgradeDetails;

Просмотреть файл

@ -1,5 +1,27 @@
@import '../../../../../fxa-content-server/app/styles/variables';
@import '../../../../../fxa-content-server/app/styles/breakpoints';
@import '../../../styles/variables';
.product-upgrade {
.c-card {
left: 45px;
}
.subscription-update-heading {
display: none;
@include min-width('tablet') {
display: block;
text-align: center;
h2 {
font-weight: normal;
margin-top: 20px;
}
}
}
.upgrade-details {
text-align: center;
width: 560px;
@ -49,10 +71,38 @@
}
form.upgrade {
margin: 0 25px;
margin: 0;
> p {
text-align: left;
}
button {
margin-top: 32px;
}
}
}
.plan-upgrade-details-component {
.plan-details-header {
border-bottom: 0;
}
p.plan-label {
color: #6d6d6e;
font-size: 11px;
font-style: normal;
font-weight: normal;
line-height: 18px;
margin: 0 0 8px 0;
&.new-plan-label {
background-image: url('../../../images/down-arrow.png');
background-position-x: center;
background-position-y: 18px;
background-repeat: no-repeat;
padding-top: 30px;
}
}
}

Просмотреть файл

@ -7,7 +7,7 @@ import MockApp from '../../../../.storybook/components/MockApp';
import { SignInLayout } from '../../../components/AppLayout';
import { CUSTOMER, SELECTED_PLAN, UPGRADE_FROM_PLAN } from './mocks';
import { CUSTOMER, SELECTED_PLAN, UPGRADE_FROM_PLAN, PROFILE } from './mocks';
import SubscriptionUpgrade, { SubscriptionUpgradeProps } from './index';
@ -76,6 +76,8 @@ const linkToUpgradeOffer = linkTo(
);
const MOCK_PROPS: SubscriptionUpgradeProps = {
isMobile: false,
profile: PROFILE,
customer: CUSTOMER,
selectedPlan: SELECTED_PLAN,
upgradeFromPlan: UPGRADE_FROM_PLAN,

Просмотреть файл

@ -5,7 +5,7 @@ import '@testing-library/jest-dom/extend-expect';
import { APIError } from '../../../lib/apiClient';
import { Plan } from '../../../store/types';
import { PlanDetail } from './index';
import { PlanUpgradeDetails, PlanDetailsCard } from './PlanUpgradeDetails';
jest.mock('../../../lib/sentry');
@ -22,7 +22,7 @@ import {
getLocalizedMessage,
} from '../../../lib/test-utils';
import { CUSTOMER, SELECTED_PLAN, UPGRADE_FROM_PLAN } from './mocks';
import { CUSTOMER, SELECTED_PLAN, UPGRADE_FROM_PLAN, PROFILE } from './mocks';
import { SignInLayout } from '../../../components/AppLayout';
@ -132,6 +132,8 @@ describe('routes/Product/SubscriptionUpgrade', () => {
describe('Legal', () => {
describe('rendering the legal checkbox Localized component', () => {
const baseProps = {
isMobile: false,
profile: PROFILE,
customer: CUSTOMER,
upgradeFromPlan: UPGRADE_FROM_PLAN,
upgradeFromSubscription: CUSTOMER.subscriptions[0],
@ -359,7 +361,7 @@ describe('routes/Product/SubscriptionUpgrade', () => {
});
});
describe('PlanDetail', () => {
describe('PlanDetailsCard', () => {
const dayBasedId = 'plan-price-day';
const weekBasedId = 'plan-price-week';
const monthBasedId = 'plan-price-month';
@ -377,7 +379,7 @@ describe('PlanDetail', () => {
function runTests(plan: Plan, expectedMsgId: string, expectedMsg: string) {
const props = { plan: plan };
const testRenderer = TestRenderer.create(<PlanDetail {...props} />);
const testRenderer = TestRenderer.create(<PlanDetailsCard {...props} />);
const testInstance = testRenderer.root;
const planPriceComponent = testInstance.findByProps({
id: expectedMsgId,
@ -577,6 +579,7 @@ const Subject = ({ props = {} }: { props?: object }) => {
const MOCK_PROPS: SubscriptionUpgradeProps = {
customer: CUSTOMER,
profile: PROFILE,
selectedPlan: SELECTED_PLAN,
upgradeFromPlan: UPGRADE_FROM_PLAN,
upgradeFromSubscription: CUSTOMER.subscriptions[0],

Просмотреть файл

@ -5,7 +5,7 @@ import {
Plan,
Customer,
CustomerSubscription,
PlanInterval,
Profile,
} from '../../../store/types';
import { SelectorReturns } from '../../../store/selectors';
import { metadataFromPlan } from '../../../store/utils';
@ -17,6 +17,7 @@ import {
getLocalizedDateString,
getLocalizedCurrency,
formatPlanPricing,
getDefaultPaymentConfirmText,
} from '../../../lib/formats';
import { useCallbackOnce } from '../../../lib/hooks';
@ -25,27 +26,18 @@ import { useValidatorState } from '../../../lib/validator';
import DialogMessage from '../../../components/DialogMessage';
import PaymentLegalBlurb from '../../../components/PaymentLegalBlurb';
import { TermsAndPrivacy } from '../../../components/TermsAndPrivacy';
import PlanUpgradeDetails from './PlanUpgradeDetails';
import Header from '../../../components/Header';
import './index.scss';
import { ProductProps } from '../index';
function getDefaultConfirmText(
amount: number,
currency: string,
interval: PlanInterval,
intervalCount: number
) {
const planPricing = formatPlanPricing(
amount,
currency,
interval,
intervalCount
);
return `I authorize Mozilla, maker of Firefox products, to charge my payment method <strong>${planPricing}</strong>, according to payment terms, until I cancel my subscription.`;
}
export type SubscriptionUpgradeProps = {
isMobile: boolean;
profile: Profile;
customer: Customer;
selectedPlan: Plan;
upgradeFromPlan: Plan;
@ -56,6 +48,8 @@ export type SubscriptionUpgradeProps = {
};
export const SubscriptionUpgrade = ({
isMobile,
profile,
customer,
selectedPlan,
upgradeFromPlan,
@ -94,19 +88,22 @@ export const SubscriptionUpgrade = ({
]
);
const {
last4: cardLast4,
exp_month: cardExpMonth,
exp_year: cardExpYear,
// TODO https://github.com/mozilla/fxa/issues/3037
// brand: cardBrand,
} = customer;
const { last4: cardLast4, brand: cardBrand } = customer;
const cardBrandLc = ('' + cardBrand).toLowerCase();
const mobileUpdateHeading = isMobile ? (
<div className="mobile-subscription-update-heading">
<div className="subscription-update-heading">
<Localized id="product-plan-upgrade-heading">
<h2>Review your upgrade</h2>
</Localized>
</div>
</div>
) : null;
return (
<div
className="product-payment product-upgrade"
data-testid="subscription-upgrade"
>
<>
{updateSubscriptionPlanStatus.error && (
<DialogMessage
className="dialog-error"
@ -118,156 +115,126 @@ export const SubscriptionUpgrade = ({
<p>{updateSubscriptionPlanStatus.error.message}</p>
</DialogMessage>
)}
<hr />
<div className="upgrade-details">
<h2>Review your upgrade details</h2>
<ol className="upgrade-plans">
<li className="from-plan">
<PlanDetail plan={upgradeFromPlan} />
</li>
<Localized id="sub-update-indicator">
<li
role="img"
aria-label="upgrade indicator"
className="upgrade-indicator"
>
&nbsp;
</li>
</Localized>
<li className="to-plan">
<PlanDetail plan={selectedPlan} />
</li>
</ol>
</div>
<hr />
<Form
data-testid="upgrade-form"
className="upgrade"
{...{ validator, onSubmit }}
>
<h3 className="billing-title">
<Localized id="sub-update-title">
<span className="title">Billing Information</span>
</Localized>
<span className="card-details">
{/* TODO: we don't have data from subhub to determine card icon
https://github.com/mozilla/fxa/issues/3037
<span className="icon">&nbsp;</span>
*/}
<Localized id="sub-update-card-ending" $last={cardLast4}>
<span className="last">Card Ending {cardLast4}</span>
</Localized>
<Localized
id="sub-update-card-exp"
$cardExpMonth={cardExpMonth}
$cardExpYear={cardExpYear}
>
<span className="expires">
Expires {cardExpMonth}/{cardExpYear}
</span>
</Localized>
</span>
</h3>
<Localized
id="sub-update-copy"
$startingDate={getLocalizedDate(
upgradeFromSubscription.current_period_end
)}
<Header {...{ profile }} />
<div className="main-content">
<div
className="product-payment product-upgrade"
data-testid="subscription-upgrade"
>
<p>
Your plan will change immediately, and youll be charged an adjusted
amount for the rest of your billing cycle. Starting{' '}
{getLocalizedDateString(upgradeFromSubscription.current_period_end)}{' '}
youll be charged the full amount.
</p>
</Localized>
<Checkbox
data-testid="confirm"
name="confirm"
onClick={engageOnce}
required
>
<Localized
id={`sub-update-confirm-${selectedPlan.interval}`}
strong={<strong></strong>}
$amount={getLocalizedCurrency(
selectedPlan.amount,
selectedPlan.currency
)}
$intervalCount={selectedPlan.interval_count}
<div
className="subscription-update-heading"
data-testid="subscription-update-heading"
>
<p>
{getDefaultConfirmText(
selectedPlan.amount,
selectedPlan.currency,
selectedPlan.interval,
selectedPlan.interval_count
)}
</p>
</Localized>
</Checkbox>
<Localized id="product-plan-upgrade-heading">
<h2>Review your upgrade</h2>
</Localized>
<p className="subheading"></p>
</div>
<div className="button-row">
<SubmitButton
data-testid="submit"
name="submit"
disabled={inProgress}
>
{inProgress ? (
<span data-testid="spinner-submit" className="spinner">
&nbsp;
</span>
) : (
<Localized id="sub-update-submit">
<span>Change Plans</span>
<div className="payment-details">
<h3 className="billing-title">
<Localized id="sub-update-title">
<span className="title">Billing information</span>
</Localized>
)}
</SubmitButton>
</h3>
<div>
<Localized
id="payment-confirmation-cc-preview"
$last4={cardLast4}
>
<p className={`c-card ${cardBrandLc}`}>
{' '}
ending in {cardLast4}
</p>
</Localized>
</div>
</div>
<Form
data-testid="upgrade-form"
className="payment upgrade"
{...{ validator, onSubmit }}
>
<hr />
<Localized
id="sub-update-copy"
$startingDate={getLocalizedDate(
upgradeFromSubscription.current_period_end
)}
>
<p>
Your plan will change immediately, and youll be charged an
adjusted amount for the rest of your billing cycle. Starting
{getLocalizedDateString(
upgradeFromSubscription.current_period_end
)}{' '}
youll be charged the full amount.
</p>
</Localized>
<hr />
<Localized
id={`sub-update-confirm-${selectedPlan.interval}`}
strong={<strong></strong>}
$amount={getLocalizedCurrency(
selectedPlan.amount,
selectedPlan.currency
)}
$intervalCount={selectedPlan.interval_count}
>
<Checkbox
data-testid="confirm"
name="confirm"
onClick={engageOnce}
required
>
{getDefaultPaymentConfirmText(
selectedPlan.amount,
selectedPlan.currency,
selectedPlan.interval,
selectedPlan.interval_count
)}
</Checkbox>
</Localized>
<hr />
<div className="button-row">
<SubmitButton
data-testid="submit"
name="submit"
disabled={inProgress}
>
{inProgress ? (
<span data-testid="spinner-submit" className="spinner">
&nbsp;
</span>
) : (
<Localized id="sub-update-submit">
<span>Confirm upgrade</span>
</Localized>
)}
</SubmitButton>
</div>
<PaymentLegalBlurb />
<TermsAndPrivacy />
</Form>
</div>
<PaymentLegalBlurb />
</Form>
</div>
);
};
export const PlanDetail = ({ plan }: { plan: Plan }) => {
const {
product_name: productName,
amount,
currency,
interval,
interval_count,
} = plan;
const { webIconURL } = metadataFromPlan(plan);
const planPrice = formatPlanPricing(
amount,
currency,
interval,
interval_count
);
return (
<div className="upgrade-plan-detail">
{webIconURL && (
<img src={webIconURL} alt={productName} height="49" width="49" />
)}
<span className="product-name">{productName}</span>
<Localized
id={`plan-price-${interval}`}
$amount={getLocalizedCurrency(amount, currency)}
$intervalCount={interval_count}
>
<span className="plan-price">{planPrice}</span>
</Localized>
</div>
<PlanUpgradeDetails
{...{
profile,
selectedPlan,
upgradeFromPlan,
isMobile,
showExpandButton: isMobile,
}}
/>
{mobileUpdateHeading}
</div>
</>
);
};

Просмотреть файл

@ -1,3 +1,4 @@
.mobile-subscription-update-heading,
.mobile-subscription-create-heading {
grid-row: 1/2;
margin: 0 auto;

Просмотреть файл

@ -158,6 +158,7 @@ export const Product = ({
return (
<SubscriptionUpgrade
{...{
isMobile,
profile: profile.result,
customer: customer.result,
selectedPlan,