Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import { headers } from 'next/headers';
import {
CartMutationProvider,
CouponForm,
Header,
MetricsWrapper,
Expand Down Expand Up @@ -65,142 +66,145 @@ export default async function CheckoutLayout({
: null;
return (
<MetricsWrapper cart={cart}>
<Header
auth={{
user: session?.user,
}}
cart={cart}
/>
<div className="flex justify-center">
{session?.user?.email && (
<section
aria-labelledby="signedin-heading"
className="mb-12 tablet:hidden"
<CartMutationProvider>
<Header
auth={{
user: session?.user,
}}
cart={cart}
/>
<div className="flex justify-center">
{session?.user?.email && (
<section
aria-labelledby="signedin-heading"
className="mb-12 tablet:hidden"
>
<SignedIn email={session.user.email} />
</section>
)}
<div
className={`mx-7 tablet:grid tablet:grid-cols-[minmax(min-content,500px)_minmax(20rem,1fr)] tablet:grid-rows-[min-content] tablet:gap-x-8 tablet:mb-auto desktop:grid-cols-[600px_1fr] ${session?.user?.email ? 'mt-12 tablet:mt-0' : ''}`}
>
<SignedIn email={session.user.email} />
</section>
)}
<div
className={`mx-7 tablet:grid tablet:grid-cols-[minmax(min-content,500px)_minmax(20rem,1fr)] tablet:grid-rows-[min-content] tablet:gap-x-8 tablet:mb-auto desktop:grid-cols-[600px_1fr] ${session?.user?.email ? 'mt-12 tablet:mt-0' : ''}`}
>
<SubscriptionTitle cart={cart} l10n={l10n} />
<SubscriptionTitle cart={cart} l10n={l10n} />

<div className="mb-6 tablet:mt-6 tablet:min-w-[18rem] tablet:max-w-xs tablet:col-start-2 tablet:row-start-1 tablet:row-span-3">
<PurchaseDetails
invoice={
cart.trialEndDate || freeTrialOffer
? cart.upcomingInvoicePreview
: (cart.latestInvoicePreview ?? cart.upcomingInvoicePreview)
}
offeringPrice={cart.offeringPrice}
purchaseDetails={purchaseDetails}
priceInterval={
<PriceInterval
l10n={l10n}
amount={cart.offeringPrice}
currency={cart.upcomingInvoicePreview.currency}
interval={cart.interval}
locale={locale}
/>
}
totalPrice={
<PriceInterval
l10n={l10n}
amount={
cart.latestInvoicePreview?.amountDue ??
cart.upcomingInvoicePreview.amountDue
}
currency={cart.upcomingInvoicePreview.currency}
interval={cart.interval}
locale={locale}
/>
}
locale={locale}
showPrices={
cart.state === CartState.START ||
cart.state === CartState.PROCESSING ||
cart.state === CartState.SUCCESS
}
freeTrialOffer={freeTrialOffer}
firstChargeAmount={cart.upcomingInvoicePreview.totalAmount}
interval={cart.interval}
cartState={cart.state}
trialStartDate={cart.trialStartDate}
trialEndDate={cart.trialEndDate}
/>
{cart.state === CartState.START && (
<section
aria-labelledby="location-heading"
className="bg-white rounded-b-lg shadow-sm shadow-grey-300 mt-6 p-4 rounded-t-lg text-base tablet:my-8"
>
<h2
id="location-heading"
className="m-0 mb-4 font-semibold text-grey-600"
<div className="mb-6 tablet:mt-6 tablet:min-w-[18rem] tablet:max-w-xs tablet:col-start-2 tablet:row-start-1 tablet:row-span-3">
<PurchaseDetails
invoice={
cart.trialEndDate || freeTrialOffer
? cart.upcomingInvoicePreview
: (cart.latestInvoicePreview ?? cart.upcomingInvoicePreview)
}
offeringPrice={cart.offeringPrice}
purchaseDetails={purchaseDetails}
priceInterval={
<PriceInterval
l10n={l10n}
amount={cart.offeringPrice}
currency={cart.upcomingInvoicePreview.currency}
interval={cart.interval}
locale={locale}
/>
}
totalPrice={
<PriceInterval
l10n={l10n}
amount={
cart.latestInvoicePreview?.amountDue ??
cart.upcomingInvoicePreview.amountDue
}
currency={cart.upcomingInvoicePreview.currency}
interval={cart.interval}
locale={locale}
/>
}
locale={locale}
showPrices={
cart.state === CartState.START ||
cart.state === CartState.PROCESSING ||
cart.state === CartState.SUCCESS
}
freeTrialOffer={freeTrialOffer}
firstChargeAmount={cart.upcomingInvoicePreview.totalAmount}
interval={cart.interval}
cartState={cart.state}
trialStartDate={cart.trialStartDate}
trialEndDate={cart.trialEndDate}
/>
{cart.state === CartState.START && (
<section
aria-labelledby="location-heading"
className="bg-white rounded-b-lg shadow-sm shadow-grey-300 mt-6 p-4 rounded-t-lg text-base tablet:my-8"
>
{l10n.getString('select-tax-location-title', 'Location')}
</h2>
<SelectTaxLocation
saveAction={async (countryCode, postalCode) => {
'use server';
const result = await updateTaxAddressAction(
cart.id,
cart.version,
resolvedParams.offeringId,
{
countryCode,
postalCode,
},
resolvedParams.interval
);
<h2
id="location-heading"
className="m-0 mb-4 font-semibold text-grey-600"
>
{l10n.getString('select-tax-location-title', 'Location')}
</h2>
<SelectTaxLocation
saveAction={async (countryCode, postalCode) => {
'use server';
const result = await updateTaxAddressAction(
cart.id,
cart.version,
resolvedParams.offeringId,
{
countryCode,
postalCode,
},
resolvedParams.interval
);

if (result.ok) {
return {
ok: true,
data: result.taxAddress,
};
} else {
return {
ok: false,
error: result.error,
};
if (result.ok) {
return {
ok: true,
data: result.taxAddress,
};
} else {
return {
ok: false,
error: result.error,
};
}
}}
cmsCountries={cms.countries}
locale={locale.substring(0, 2)}
productName={purchaseDetails.productName}
unsupportedLocations={
config.location.subscriptionsUnsupportedLocations
}
}}
cmsCountries={cms.countries}
locale={locale.substring(0, 2)}
productName={purchaseDetails.productName}
unsupportedLocations={
config.location.subscriptionsUnsupportedLocations
}
countryCode={cart.taxAddress?.countryCode}
postalCode={cart.taxAddress?.postalCode}
currentCurrency={cart.currency}
showNewTaxRateInfoMessage={cart.hasActiveSubscriptions}
countryCode={cart.taxAddress?.countryCode}
postalCode={cart.taxAddress?.postalCode}
currentCurrency={cart.currency}
cartVersion={cart.version}
showNewTaxRateInfoMessage={cart.hasActiveSubscriptions}
/>
</section>
)}
{cart.state !== CartState.FAIL && (
<CouponForm
cartId={cart.id}
cartVersion={cart.version}
promoCode={cart.couponCode}
readOnly={cart.state === CartState.START ? false : true}
/>
</section>
)}
{cart.state !== CartState.FAIL && (
<CouponForm
cartId={cart.id}
cartVersion={cart.version}
promoCode={cart.couponCode}
readOnly={cart.state === CartState.START ? false : true}
/>
)}
</div>
)}
</div>

<div className="bg-white rounded-b-lg shadow-sm shadow-grey-300 border-t-0 mb-6 pt-4 px-4 pb-14 rounded-t-lg text-grey-600 tablet:clip-shadow tablet:rounded-t-none desktop:px-12 desktop:pb-12">
{children}
<TermsAndPrivacy
l10n={l10n}
{...cart}
{...purchaseDetails}
{...(cms.commonContent.localizations.at(0) || cms.commonContent)}
contentServerUrl={config.contentServerUrl}
showFXALinks={true}
/>
<div className="bg-white rounded-b-lg shadow-sm shadow-grey-300 border-t-0 mb-6 pt-4 px-4 pb-14 rounded-t-lg text-grey-600 tablet:clip-shadow tablet:rounded-t-none desktop:px-12 desktop:pb-12">
{children}
<TermsAndPrivacy
l10n={l10n}
{...cart}
{...purchaseDetails}
{...(cms.commonContent.localizations.at(0) || cms.commonContent)}
contentServerUrl={config.contentServerUrl}
showFXALinks={true}
/>
</div>
</div>
</div>
</div>
</CartMutationProvider>
</MetricsWrapper>
);
}
1 change: 1 addition & 0 deletions libs/payments/ui/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ export * from './lib/client/components/SubscriptionContent';
export * from './lib/client/components/MetricsWrapper';
export * from './lib/client/components/StripeWrapper';
export * from './lib/client/hooks/useGleanMetrics';
export * from './lib/client/providers/CartMutationProvider';
export * from './lib/client/providers/Providers';
export * from './lib/constants';
export * from './lib/utils/helpers';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,17 @@ import {
useRouter,
useSearchParams,
} from 'next/navigation';
import { useEffect, useMemo, useState } from 'react';
import {
useEffect,
useMemo,
useState,
useContext,
} from 'react';

import type { Interval } from '@fxa/payments/metrics/client';
import { BaseButton, ButtonVariant, CheckoutCheckbox } from '@fxa/payments/ui';
import LockImage from '@fxa/shared/assets/images/lock.svg';
import { CartMutationContext } from '../../providers/CartMutationProvider';
import { useCallbackOnce } from '../../hooks/useCallbackOnce';
import { useGleanMetrics } from '../../hooks/useGleanMetrics';
import {
Expand Down Expand Up @@ -126,6 +132,8 @@ export function CheckoutForm({
searchParamsRecord[key] = value;
}

const { isPending: cartMutationPending } = useContext(CartMutationContext);

const [formEnabled, setFormEnabled] = useState(false);
const [showConsentError, setShowConsentError] = useState(false);
const [isPaymentElementLoading, setIsPaymentElementLoading] = useState(true);
Expand Down Expand Up @@ -225,8 +233,8 @@ export function CheckoutForm({
) => {
event.preventDefault();

if (!stripe || !elements || loading) {
// Stripe.js hasn't yet loaded.
if (!stripe || !elements || loading || cartMutationPending) {
// Stripe.js hasn't yet loaded, or a cart mutation is in progress.
// Make sure to disable form submission until Stripe.js has loaded.
return;
}
Expand Down Expand Up @@ -443,15 +451,18 @@ export function CheckoutForm({
cartVersion={cart.version}
cartCurrency={cart.currency}
searchParams={searchParams}
disabled={loading || !formEnabled}
disabled={loading || cartMutationPending || !formEnabled}
/>
) : (
<BaseButton
className="h-12 mt-10 w-full"
type="submit"
variant={ButtonVariant.Primary}
aria-disabled={
!formEnabled || (isStripe && !stripeFieldsComplete) || loading
!formEnabled ||
(isStripe && !stripeFieldsComplete) ||
loading ||
cartMutationPending
}
>
{loading ? (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@ const baseProps = {
countryCode: undefined,
postalCode: undefined,
currentCurrency: 'usd',
cartVersion: 0,
showNewTaxRateInfoMessage: false,
};

Expand Down Expand Up @@ -159,7 +160,7 @@ describe('SelectTaxLocation', () => {
});

describe('Successful save', () => {
it('calls saveAction, collapses to the updated location, and shows the success alert', async () => {
it('calls saveAction, collapses to the updated location, and shows the success alert once cart.version advances', async () => {
mockValidateAndFormatPostalCode.mockResolvedValue({
isValid: true,
formattedPostalCode: 'A1B 2C3',
Expand All @@ -169,7 +170,7 @@ describe('SelectTaxLocation', () => {
data: { countryCode: 'CA', postalCode: 'A1B 2C3' },
});

render(<SelectTaxLocation {...baseProps} />);
const { rerender } = render(<SelectTaxLocation {...baseProps} />);

await waitFor(() => {
expect(screen.getByRole('option', { name: 'Canada' })).toBeInTheDocument();
Expand All @@ -195,8 +196,16 @@ describe('SelectTaxLocation', () => {
});
expect(screen.getByText('CA, A1B 2C3')).toBeInTheDocument();
expect(
screen.getByText(/Your location has been updated/i)
).toBeInTheDocument();
screen.queryByText(/Your location has been updated/i)
).not.toBeInTheDocument();

rerender(<SelectTaxLocation {...baseProps} cartVersion={1} />);

await waitFor(() => {
expect(
screen.getByText(/Your location has been updated/i)
).toBeInTheDocument();
});
});
});

Expand Down
Loading