feat(next): reorg checkout pages

Because:

* Reorganize checkout pages to include intent and cartId in path, and
  allow for landing page.

This commit:

* Reorganizes checkout pages
* Add checkout landing page
* Add getLocaleFromRequest and update l10n locale logic

Closes #FXA-7804
This commit is contained in:
Reino Muhl 2024-02-21 15:19:30 -05:00
Родитель 0b55f8816c
Коммит 275ecdb0b8
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: C86660FCF998897A
12 изменённых файлов: 168 добавлений и 85 удалений

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

@ -3,41 +3,47 @@ import Image from 'next/image';
import Link from 'next/link';
import { PurchaseDetails, TermsAndPrivacy } from '@fxa/payments/ui/server';
import { getBundle } from '@fxa/shared/l10n';
import { getBundle, getLocaleFromRequest } from '@fxa/shared/l10n';
import { getCartData, getContentfulContent } from '../../_lib/apiClient';
import checkLogo from '../../../images/check.svg';
import errorIcon from '../../../images/error.svg';
import { getCartData, getContentfulContent } from '../../../../_lib/apiClient';
import checkLogo from '../../../../../images/check.svg';
import errorIcon from '../../../../../images/error.svg';
import { CheckoutSearchParams } from '../../layout';
// import { app } from '../../_nestapp/app';
interface CheckoutParams {
offeringId: string;
}
// forces dynamic rendering
// https://nextjs.org/docs/app/api-reference/file-conventions/route-segment-config
export const dynamic = 'force-dynamic';
export default async function Error({ params }: { params: CheckoutParams }) {
// TODO - Fetch Cart ID from cookie
// https://nextjs.org/docs/app/api-reference/functions/cookies
const cartId = 'cart-uuid';
// TODO - Fetch locale from params
// Possible solution could be as link below
// https://nextjs.org/docs/app/building-your-application/routing/internationalization
const locale = 'en-US';
// Temporary code for demo purposes only - Replaced as part of FXA-8822
const demoSupportedLanguages = ['en-US', 'fr-FR', 'es-ES', 'de-DE'];
interface CheckoutParams {
offeringId: string;
cartId: string;
}
export default async function CheckoutError({
params,
searchParams,
}: {
params: CheckoutParams;
searchParams: CheckoutSearchParams;
}) {
const headersList = headers();
const locale = getLocaleFromRequest(
searchParams,
headersList.get('accept-language'),
demoSupportedLanguages
);
const contentfulData = getContentfulContent(params.offeringId, locale);
const cartData = getCartData(cartId);
const cartData = getCartData(params.cartId);
const [contentful, cart] = await Promise.all([contentfulData, cartData]);
/* eslint-disable @typescript-eslint/no-unused-vars */
// const cartService = await app.getCartService();
const languages = headers()
.get('Accept-Language')
?.split(',')
.map((language) => language.split(';')[0]);
const l10n = await getBundle(languages);
const l10n = await getBundle([locale]);
const getErrorReason = (reason: string) => {
switch (reason) {
@ -80,6 +86,7 @@ export default async function Error({ params }: { params: CheckoutParams }) {
aria-label="Purchase details"
>
<PurchaseDetails
locale={locale}
interval={cart.interval}
invoice={cart.nextInvoice}
purchaseDetails={contentful.purchaseDetails}
@ -110,6 +117,7 @@ export default async function Error({ params }: { params: CheckoutParams }) {
</section>
<TermsAndPrivacy
locale={locale}
{...cart}
{...contentful.commonContent}
{...contentful.purchaseDetails}

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

@ -1,26 +1,37 @@
import { PurchaseDetails, TermsAndPrivacy } from '@fxa/payments/ui/server';
import { getCartData, getContentfulContent } from '../../_lib/apiClient';
import { app } from '../../_nestapp/app';
import { getCartData, getContentfulContent } from '../../../_lib/apiClient';
import { app } from '../../../_nestapp/app';
import { headers } from 'next/headers';
import { getLocaleFromRequest } from '@fxa/shared/l10n';
import { CheckoutSearchParams } from '../layout';
import { auth, signIn, signOut } from 'apps/payments/next/auth';
interface CheckoutParams {
offeringId: string;
}
export const dynamic = 'force-dynamic';
export default async function Index({ params }: { params: CheckoutParams }) {
// TODO - Fetch Cart ID from cookie
// https://nextjs.org/docs/app/api-reference/functions/cookies
const cartId = 'cart-uuid';
// TODO - Fetch locale from params
// Possible solution could be as link below
// https://nextjs.org/docs/app/building-your-application/routing/internationalization
const locale = 'en-US';
interface CheckoutParams {
offeringId: string;
cartId: string;
}
// Temporary code for demo purposes only - Replaced as part of FXA-8822
const demoSupportedLanguages = ['en-US', 'fr-FR', 'es-ES', 'de-DE'];
export default async function Checkout({
params,
searchParams,
}: {
params: CheckoutParams;
searchParams: CheckoutSearchParams;
}) {
const headersList = headers();
const locale = getLocaleFromRequest(
searchParams,
headersList.get('accept-language'),
demoSupportedLanguages
);
const contentfulData = getContentfulContent(params.offeringId, locale);
const cartData = getCartData(cartId);
const cartData = getCartData(params.cartId);
const [contentful, cart] = await Promise.all([contentfulData, cartData]);
/* eslint-disable @typescript-eslint/no-unused-vars */
const cartService = await app.getCartService();
@ -35,6 +46,7 @@ export default async function Index({ params }: { params: CheckoutParams }) {
<section className="payment-panel" aria-label="Purchase details">
<PurchaseDetails
interval={cart.interval}
locale={locale}
invoice={cart.nextInvoice}
purchaseDetails={contentful.purchaseDetails}
/>
@ -90,6 +102,7 @@ export default async function Index({ params }: { params: CheckoutParams }) {
</section>
<TermsAndPrivacy
locale={locale}
{...cart}
{...contentful.commonContent}
{...contentful.purchaseDetails}

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

@ -1,25 +1,26 @@
import { PurchaseDetails, TermsAndPrivacy } from '@fxa/payments/ui/server';
import { getCartData, getContentfulContent } from '../../_lib/apiClient';
import { getCartData, getContentfulContent } from '../../../../_lib/apiClient';
import { headers } from 'next/headers';
import Image from 'next/image';
import checkLogo from '../../../images/check.svg';
import circledConfirm from '../../../images/circled-confirm.svg';
import { formatPlanPricing } from '../../../../../../libs/payments/ui/src/lib/utils/helpers';
import checkLogo from '../../../../../images/check.svg';
import circledConfirm from '../../../../../images/circled-confirm.svg';
import { formatPlanPricing } from '../../../../../../../../libs/payments/ui/src/lib/utils/helpers';
import {
getBundle,
getFormattedMsg,
getLocaleFromRequest,
getLocalizedCurrency,
getLocalizedDate,
getLocalizedDateString,
} from '@fxa/shared/l10n';
import { CheckoutSearchParams } from '../../layout';
// import { app } from '../../_nestapp/app';
interface CheckoutParams {
offeringId: string;
}
export const dynamic = 'force-dynamic';
// Temporary code for demo purposes only - Replaced as part of FXA-8822
const demoSupportedLanguages = ['en-US', 'fr-FR', 'es-ES', 'de-DE'];
type ConfirmationDetailProps = {
title: string;
detail1: string | Promise<string>;
@ -42,17 +43,27 @@ const ConfirmationDetail = ({
);
};
export default async function Page({ params }: { params: CheckoutParams }) {
// TODO - Fetch Cart ID from cookie
// https://nextjs.org/docs/app/api-reference/functions/cookies
const cartId = 'cart-uuid';
// TODO - Fetch locale from params
// Possible solution could be as link below
// https://nextjs.org/docs/app/building-your-application/routing/internationalization
const locale = 'en-US';
interface CheckoutParams {
offeringId: string;
cartId: string;
}
export default async function CheckoutSuccess({
params,
searchParams,
}: {
params: CheckoutParams;
searchParams: CheckoutSearchParams;
}) {
const headersList = headers();
const locale = getLocaleFromRequest(
searchParams,
headersList.get('accept-language'),
demoSupportedLanguages
);
const contentfulData = getContentfulContent(params.offeringId, locale);
const cartData = getCartData(cartId);
const cartData = getCartData(params.cartId);
const [contentful, cart] = await Promise.all([contentfulData, cartData]);
/* eslint-disable @typescript-eslint/no-unused-vars */
// const cartService = await app.getCartService();
@ -63,11 +74,7 @@ export default async function Page({ params }: { params: CheckoutParams }) {
cart.interval
);
const languages = headers()
.get('Accept-Language')
?.split(',')
.map((language) => language.split(';')[0]);
const l10n = await getBundle(languages);
const l10n = await getBundle([locale]);
return (
<>
@ -87,6 +94,7 @@ export default async function Page({ params }: { params: CheckoutParams }) {
<section className="payment-panel" aria-label="Purchase details">
<PurchaseDetails
locale={locale}
interval={cart.interval}
invoice={cart.nextInvoice}
purchaseDetails={contentful.purchaseDetails}
@ -182,6 +190,7 @@ export default async function Page({ params }: { params: CheckoutParams }) {
</section>
<TermsAndPrivacy
locale={locale}
{...cart}
{...contentful.commonContent}
{...contentful.purchaseDetails}

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

@ -0,0 +1,20 @@
// TODO - Replace these placeholders as part of FXA-8227
export const metadata = {
title: 'Mozilla accounts',
description: 'Mozilla accounts',
};
export interface CheckoutSearchParams {
interval?: string;
promotion_code?: string;
experiment?: string;
locale?: string;
}
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return <>{children}</>;
}

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

@ -0,0 +1,3 @@
export default async function CheckoutNew() {
return <></>;
}

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

@ -50,7 +50,7 @@ export async function fetchFromContentful() {
export async function fetchCartData(cartId: string) {
return {
id: '',
id: 'b6115e72-2a3f-4de8-a58a-e231bfeea85d',
// state: CartState.START,
state: CartState.FAIL,
// errorReasonId: CartErrorReasonId.BASIC_ERROR,

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

@ -14,7 +14,7 @@ export default function Index() {
<h2>123Done - Monthly</h2>
<Link
className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded"
href="/123done/checkout?interval=monthly"
href="/123done/checkout/b6115e72-2a3f-4de8-a58a-e231bfeea85d?interval=monthly"
>
Redirect
</Link>
@ -23,7 +23,7 @@ export default function Index() {
<h2>123Done - Yearly</h2>
<Link
className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded"
href="/123done/checkout?interval=yearly"
href="/123done/checkout/b6115e72-2a3f-4de8-a58a-e231bfeea85d?interval=yearly"
>
Redirect
</Link>

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

@ -6,7 +6,6 @@ import {
getLocalizedCurrencyString,
} from '@fxa/shared/l10n';
import { FluentBundle } from '@fluent/bundle';
import { headers } from 'next/headers';
import Image from 'next/image';
import { formatPlanPricing } from '../utils/helpers';
import '../../styles/index.css';
@ -57,6 +56,7 @@ export const ListLabelItem = ({
type PurchaseDetailsProps = {
interval: string;
locale: string;
invoice: Invoice;
purchaseDetails: {
details: string[];
@ -75,20 +75,12 @@ export async function PurchaseDetails(props: PurchaseDetailsProps) {
(taxAmount) => !taxAmount.inclusive
);
// TODO - Temporary
// Identify an approach to ensure we don't have to perform this logic
// in every component/page that requires localization.
const languages = headers()
.get('Accept-Language')
?.split(',')
.map((language) => language.split(';')[0]);
// TODO
// Move to instantiation on start up. Ideally getBundle's, generateBundle, is only called once at startup,
// and then that instance is used for all requests.
// Approach 1 (Experimental): https://nextjs.org/docs/app/building-your-application/optimizing/instrumentation
// Approach 2 (Node global): https://github.com/vercel/next.js/blob/canary/examples/with-knex/knex/index.js#L13
const l10n = await getBundle(languages);
const l10n = await getBundle([props.locale]);
return (
<div className="component-card text-sm px-4 rounded-t-none tablet:rounded-t-lg">

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

@ -1,6 +1,5 @@
import { FluentBundle } from '@fluent/bundle';
import { getBundle } from '@fxa/shared/l10n';
import { headers } from 'next/headers';
import {
GenericTermItem,
GenericTermsListItem,
@ -59,6 +58,7 @@ function GenericTerms({
}
export interface TermsAndPrivacyProps {
locale: string;
paymentProvider?: PaymentProvider;
productName: string;
termsOfServiceUrl: string;
@ -68,6 +68,7 @@ export interface TermsAndPrivacyProps {
}
export async function TermsAndPrivacy({
locale,
paymentProvider,
productName,
termsOfServiceUrl,
@ -88,20 +89,12 @@ export async function TermsAndPrivacy({
),
];
// TODO - Temporary
// Identify an approach to ensure we don't have to perform this logic
// in every component/page that requires localization.
const languages = headers()
.get('Accept-Language')
?.split(',')
.map((language) => language.split(';')[0]);
// TODO
// Move to instantiation on start up. Ideally getBundle's, generateBundle, is only called once at startup,
// and then that instance is used for all requests.
// Approach 1 (Experimental): https://nextjs.org/docs/app/building-your-application/optimizing/instrumentation
// Approach 2 (Node global): https://github.com/vercel/next.js/blob/canary/examples/with-knex/knex/index.js#L13
const l10n = await getBundle(languages);
const l10n = await getBundle([locale]);
return (
<aside className="pt-14" aria-label="Terms and Privacy Notices">

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

@ -1,7 +1,7 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import { determineLocale } from './determine-locale';
import { determineLocale, getLocaleFromRequest } from './determine-locale';
describe('l10n/determineLocale:', () => {
it('finds a locale', () => {
@ -58,4 +58,26 @@ describe('l10n/determineLocale:', () => {
expect(determineLocale('en;q=0.5, fr;q=1.1')).toEqual('fr');
expect(determineLocale('en;q=0.5, fr;q=-.1')).toEqual('en');
});
describe('getLocaleFromRequest', () => {
it('return searchParams', () => {
expect(getLocaleFromRequest({ locale: 'fr-FR' }, null)).toEqual('fr');
});
it('return searchParams in supportedLanguages', () => {
expect(
getLocaleFromRequest({ locale: 'ra-ND' }, null, ['ra-ND'])
).toEqual('ra-ND');
});
it('return accept language', () => {
expect(getLocaleFromRequest({}, 'en-US;q=0.1, es-MX;q=0.8')).toEqual(
'es-MX'
);
});
it('return default locale', () => {
expect(getLocaleFromRequest({}, null)).toEqual('en-US');
});
});
});

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

@ -1,8 +1,31 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import { DEFAULT_LOCALE } from './l10n';
import { parseAcceptLanguage } from './parse-accept-language';
/**
* Get the best fitting locale, prioritizing request search params, followed by request header AcceptLanguage and DEFAULT_LOCALE as default
* @param searchParams - Search parameters of the request
* @param acceptLanguage - Accept language from request header
* @returns The best fitting locale
*/
export function getLocaleFromRequest(
searchParams: { locale?: string },
acceptLanguage: string | null,
supportedLanguages?: string[]
) {
if (searchParams.locale) {
return determineLocale(searchParams?.locale, supportedLanguages);
}
if (acceptLanguage) {
return determineLocale(acceptLanguage, supportedLanguages);
}
return DEFAULT_LOCALE;
}
/**
* Given a set of supported languages and an accept-language http header value, this resolves language that fits best.
* @param acceptLanguage - The accept-language http header value

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

@ -8,7 +8,7 @@ import * as path from 'path';
// fr: new URL('./fr.ftl', import.meta.url),
// };
const DEFAULT_LOCALE = 'en-US';
export const DEFAULT_LOCALE = 'en-US';
// const AVAILABLE_LOCALES = {
// 'en-US': 'English',
// fr: 'French',