Merge pull request #18275 from mozilla/FXA-7605

feat(payments,shared): Update cart with saved tax location
This commit is contained in:
Lisa Chan 2025-01-27 09:21:14 -05:00 коммит произвёл GitHub
Родитель 4bb97b4a0c c872efd57b
Коммит bc62e1f479
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: B5690EEEBB952194
9 изменённых файлов: 94 добавлений и 30 удалений

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

@ -56,6 +56,9 @@ export default async function RootLayout({
cartDataPromise,
sessionPromise,
]);
const purchaseDetails =
cms.defaultPurchase.purchaseDetails.localizations.at(0) ||
cms.defaultPurchase.purchaseDetails;
return (
<MetricsWrapper cart={cart}>
{session?.user?.email && (
@ -79,33 +82,31 @@ export default async function RootLayout({
listAmount={cart.upcomingInvoicePreview.listAmount}
/>
}
purchaseDetails={
cms.defaultPurchase.purchaseDetails.localizations.at(0) ||
cms.defaultPurchase.purchaseDetails
}
purchaseDetails={purchaseDetails}
>
<Details
l10n={l10n}
interval={cart.interval}
invoice={cart.upcomingInvoicePreview}
purchaseDetails={
cms.defaultPurchase.purchaseDetails.localizations.at(0) ||
cms.defaultPurchase.purchaseDetails
}
purchaseDetails={purchaseDetails}
/>
</PurchaseDetails>
<SelectTaxLocation
cartId={cart.id}
cartVersion={cart.version}
cmsCountries={cms.countries}
locale={locale.substring(0, 2)}
productName={purchaseDetails.productName}
unsupportedLocations={config.subscriptionsUnsupportedLocations}
countryCode={cart.taxAddress?.countryCode}
postalCode={cart.taxAddress?.postalCode}
/>
<CouponForm
cartId={cart.id}
cartVersion={cart.version}
promoCode={cart.couponCode}
readOnly={false}
/>
<SelectTaxLocation
locale={locale.substring(0, 2)}
unsupportedLocations={config.subscriptionsUnsupportedLocations}
countryCode={cart.taxAddress?.countryCode}
postalCode={cart.taxAddress?.postalCode}
/>
</section>
<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">
@ -113,9 +114,8 @@ export default async function RootLayout({
<TermsAndPrivacy
l10n={l10n}
{...cart}
{...purchaseDetails}
{...(cms.commonContent.localizations.at(0) || cms.commonContent)}
{...(cms.defaultPurchase.purchaseDetails.localizations.at(0) ||
cms.defaultPurchase.purchaseDetails)}
contentServerUrl={config.contentServerUrl}
showFXALinks={true}
/>

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

@ -9,6 +9,9 @@ select-tax-location-country-code-label = Country
select-tax-location-country-code-placeholder = Select your country
select-tax-location-error-missing-country-code = Please select your country
# $productName (String) - The name of the product to be downloaded, e.g. Mozilla VPN
select-tax-location-product-not-available = { $productName } is not available in this location.
select-tax-location-postal-code-label = Postal Code
select-tax-location-postal-code =
.placeholder = Enter your postal code

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

@ -9,7 +9,7 @@ import * as Form from '@radix-ui/react-form';
import countries from 'i18n-iso-countries';
import { useEffect, useState } from 'react';
import { ButtonVariant } from '@fxa/payments/ui';
import { validatePostalCode } from '@fxa/payments/ui/actions';
import { updateCartAction, validatePostalCode } from '@fxa/payments/ui/actions';
import { SubmitButton } from '../SubmitButton';
interface CollapsedProps {
@ -37,7 +37,9 @@ const Collapsed = ({
variant={ButtonVariant.Secondary}
data-testid="tax-location-edit-button"
>
<Localized id="select-tax-location-edit-button">Edit</Localized>
<Localized id="select-tax-location-edit-button">
<p>Edit</p>
</Localized>
</SubmitButton>
</Form.Submit>
</span>
@ -55,7 +57,9 @@ const Collapsed = ({
};
interface ExpandedProps {
cmsCountryCodes: string[];
locale: string;
productName: string;
unsupportedLocations: string;
countryCode: string | undefined;
postalCode: string | undefined;
@ -63,7 +67,9 @@ interface ExpandedProps {
}
const Expanded = ({
cmsCountryCodes,
locale,
productName,
unsupportedLocations,
countryCode,
postalCode,
@ -82,6 +88,7 @@ const Expanded = ({
[key: string]: boolean;
}>({
missingCountryCode: false,
productNotAvailable: false,
unsupportedCountry: false,
invalidPostalCode: false,
locationNotUpdated: false,
@ -113,6 +120,7 @@ const Expanded = ({
setServerErrors((prev) => ({
...prev,
missingCountryCode: false,
productNotAvailable: false,
unsupportedCountries: false,
}));
@ -122,10 +130,35 @@ const Expanded = ({
setSelectedCountryCode(countryCode);
if (unsupportedLocations.includes(countryCode)) {
setServerErrors((prev) => ({ ...prev, unsupportedCountry: true }));
// If the selected location is not supported per TOS, it is not necessary to
// also inform the customer that the product is not available in their location.
if (
unsupportedLocations.includes(countryCode) &&
!cmsCountryCodes.includes(countryCode)
) {
setServerErrors((prev) => ({
...prev,
productNotAvailable: false,
unsupportedCountry: true,
}));
} else if (unsupportedLocations.includes(countryCode)) {
setServerErrors((prev) => ({
...prev,
productNotAvailable: false,
unsupportedCountry: true,
}));
} else if (!cmsCountryCodes.includes(countryCode)) {
setServerErrors((prev) => ({
...prev,
productNotAvailable: true,
unsupportedCountry: false,
}));
} else {
setServerErrors((prev) => ({ ...prev, unsupportedCountry: false }));
setServerErrors((prev) => ({
...prev,
productNotAvailable: false,
unsupportedCountry: false,
}));
}
};
@ -206,6 +239,15 @@ const Expanded = ({
</p>
</Localized>
</Form.Message>
{serverErrors.productNotAvailable && (
<Form.Message>
<Localized id="select-tax-location-product-not-available">
<p className="mt-1 text-alert-red" role="alert">
{productName} is not available in this location.
</p>
</Localized>
</Form.Message>
)}
{serverErrors.unsupportedCountry && (
<Form.Message>
<Localized id="next-location-unsupported">
@ -282,7 +324,9 @@ const Expanded = ({
data-testid="tax-location-save-button"
variant={ButtonVariant.Secondary}
>
<Localized id="select-tax-location-save-button">Save</Localized>
<Localized id="select-tax-location-save-button">
<p>Save</p>
</Localized>
</SubmitButton>
</Form.Submit>
</Form.Root>
@ -290,14 +334,22 @@ const Expanded = ({
};
interface SelectTaxLocationProps {
cartId: string;
cartVersion: number;
cmsCountries: string[];
locale: string;
productName: string;
unsupportedLocations: string;
countryCode: string | undefined;
postalCode: string | undefined;
}
export function SelectTaxLocation({
cartId,
cartVersion,
cmsCountries,
locale,
productName,
unsupportedLocations,
countryCode,
postalCode,
@ -306,6 +358,7 @@ export function SelectTaxLocation({
!countryCode || !postalCode
);
const [alertStatus, setAlertStatus] = useState<boolean>(false);
const cmsCountryCodes = cmsCountries.map((country) => country.slice(0, 2));
return (
<div
@ -318,15 +371,19 @@ export function SelectTaxLocation({
{expanded ? (
<Expanded
cmsCountryCodes={cmsCountryCodes}
locale={locale}
productName={productName}
unsupportedLocations={unsupportedLocations}
countryCode={countryCode}
postalCode={postalCode}
saveAction={(countryCode: string, postalCode: string) => {
saveAction={async (countryCode: string, postalCode: string) => {
setExpanded(false);
// Call function to save to Cart
// await saveTaxLocationAction(countryCode, postalCode);
await updateCartAction(cartId, cartVersion, {
taxAddress: { countryCode, postalCode },
});
setAlertStatus(true);
}}
/>

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

@ -1,7 +1,8 @@
import type { CodegenConfig } from '@graphql-codegen/cli';
const STRAPI_GRAPHQL_API_URL = process.env.STRAPI_GRAPHQL_API_URL;
const STRAPI_API_KEY = process.env.STRAPI_API_KEY;
const STRAPI_GRAPHQL_API_URL =
process.env.STRAPI_CLIENT_CONFIG__GRAPHQL_API_URI;
const STRAPI_API_KEY = process.env.STRAPI_CLIENT_CONFIG__API_KEY;
if (!STRAPI_GRAPHQL_API_URL || !STRAPI_API_KEY) {
throw new Error('Please provide a valid Strapi API URL and API key');

4
libs/shared/cms/src/__generated__/gql.ts сгенерированный
Просмотреть файл

@ -18,7 +18,7 @@ const documents = {
"\n query EligibilityContentByPlanIds($stripePlanIds: [String]!) {\n purchases(\n filters: {\n or: [\n { stripePlanChoices: { stripePlanChoice: { in: $stripePlanIds } } }\n {\n offering: {\n stripeLegacyPlans: { stripeLegacyPlan: { in: $stripePlanIds } }\n }\n }\n ]\n }\n pagination: { limit: 200 }\n ) {\n stripePlanChoices {\n stripePlanChoice\n }\n offering {\n apiIdentifier\n stripeProductId\n stripeLegacyPlans(pagination: { limit: 200 }) {\n stripeLegacyPlan\n }\n countries\n subGroups {\n groupName\n offerings {\n apiIdentifier\n stripeProductId\n stripeLegacyPlans(pagination: { limit: 200 }) {\n stripeLegacyPlan\n }\n countries\n }\n }\n }\n }\n }\n": types.EligibilityContentByPlanIdsDocument,
"\n query Locales {\n i18NLocales(pagination: { limit: 100 }) {\n code\n }\n }\n": types.LocalesDocument,
"\n query Offering($id: ID!, $locale: String!) {\n offering(documentId: $id) {\n stripeProductId\n countries\n defaultPurchase {\n purchaseDetails {\n productName\n details\n subtitle\n webIcon\n localizations(filters: { locale: { eq: $locale } }) {\n productName\n details\n subtitle\n webIcon\n }\n }\n }\n }\n }\n": types.OfferingDocument,
"\n query PageContentForOffering($locale: String!, $apiIdentifier: String!) {\n offerings(\n filters: { apiIdentifier: { eq: $apiIdentifier } }\n pagination: { limit: 200 }\n ) {\n apiIdentifier\n stripeProductId\n defaultPurchase {\n purchaseDetails {\n details\n productName\n subtitle\n webIcon\n localizations(filters: { locale: { eq: $locale } }) {\n details\n productName\n subtitle\n webIcon\n }\n }\n }\n commonContent {\n privacyNoticeUrl\n privacyNoticeDownloadUrl\n termsOfServiceUrl\n termsOfServiceDownloadUrl\n cancellationUrl\n emailIcon\n successActionButtonUrl\n successActionButtonLabel\n newsletterLabelTextCode\n newsletterSlug\n localizations(filters: { locale: { eq: $locale } }) {\n privacyNoticeUrl\n privacyNoticeDownloadUrl\n termsOfServiceUrl\n termsOfServiceDownloadUrl\n cancellationUrl\n emailIcon\n successActionButtonUrl\n successActionButtonLabel\n newsletterLabelTextCode\n newsletterSlug\n }\n }\n }\n }\n": types.PageContentForOfferingDocument,
"\n query PageContentForOffering($locale: String!, $apiIdentifier: String!) {\n offerings(\n filters: { apiIdentifier: { eq: $apiIdentifier } }\n pagination: { limit: 200 }\n ) {\n apiIdentifier\n countries\n stripeProductId\n defaultPurchase {\n purchaseDetails {\n details\n productName\n subtitle\n webIcon\n localizations(filters: { locale: { eq: $locale } }) {\n details\n productName\n subtitle\n webIcon\n }\n }\n }\n commonContent {\n privacyNoticeUrl\n privacyNoticeDownloadUrl\n termsOfServiceUrl\n termsOfServiceDownloadUrl\n cancellationUrl\n emailIcon\n successActionButtonUrl\n successActionButtonLabel\n newsletterLabelTextCode\n newsletterSlug\n localizations(filters: { locale: { eq: $locale } }) {\n privacyNoticeUrl\n privacyNoticeDownloadUrl\n termsOfServiceUrl\n termsOfServiceDownloadUrl\n cancellationUrl\n emailIcon\n successActionButtonUrl\n successActionButtonLabel\n newsletterLabelTextCode\n newsletterSlug\n }\n }\n }\n }\n": types.PageContentForOfferingDocument,
"\n query PurchaseWithDetailsOfferingContent(\n $locale: String!\n $stripePlanIds: [String]!\n ) {\n purchases(\n filters: {\n or: [\n { stripePlanChoices: { stripePlanChoice: { in: $stripePlanIds } } }\n {\n offering: {\n stripeLegacyPlans: { stripeLegacyPlan: { in: $stripePlanIds } }\n }\n }\n ]\n }\n pagination: { limit: 500 }\n ) {\n stripePlanChoices {\n stripePlanChoice\n }\n purchaseDetails {\n details\n productName\n subtitle\n webIcon\n localizations(filters: { locale: { eq: $locale } }) {\n details\n productName\n subtitle\n webIcon\n }\n }\n offering {\n stripeProductId\n stripeLegacyPlans(pagination: { limit: 200 }) {\n stripeLegacyPlan\n }\n commonContent {\n privacyNoticeUrl\n privacyNoticeDownloadUrl\n termsOfServiceUrl\n termsOfServiceDownloadUrl\n cancellationUrl\n emailIcon\n successActionButtonUrl\n successActionButtonLabel\n newsletterLabelTextCode\n newsletterSlug\n localizations(filters: { locale: { eq: $locale } }) {\n privacyNoticeUrl\n privacyNoticeDownloadUrl\n termsOfServiceUrl\n termsOfServiceDownloadUrl\n cancellationUrl\n emailIcon\n successActionButtonUrl\n successActionButtonLabel\n newsletterLabelTextCode\n newsletterSlug\n }\n }\n }\n }\n }\n": types.PurchaseWithDetailsOfferingContentDocument,
"\n query ServicesWithCapabilities {\n services(pagination: { limit: 500 }) {\n oauthClientId\n capabilities {\n slug\n }\n }\n }\n": types.ServicesWithCapabilitiesDocument,
};
@ -60,7 +60,7 @@ export function graphql(source: "\n query Offering($id: ID!, $locale: String!)
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n query PageContentForOffering($locale: String!, $apiIdentifier: String!) {\n offerings(\n filters: { apiIdentifier: { eq: $apiIdentifier } }\n pagination: { limit: 200 }\n ) {\n apiIdentifier\n stripeProductId\n defaultPurchase {\n purchaseDetails {\n details\n productName\n subtitle\n webIcon\n localizations(filters: { locale: { eq: $locale } }) {\n details\n productName\n subtitle\n webIcon\n }\n }\n }\n commonContent {\n privacyNoticeUrl\n privacyNoticeDownloadUrl\n termsOfServiceUrl\n termsOfServiceDownloadUrl\n cancellationUrl\n emailIcon\n successActionButtonUrl\n successActionButtonLabel\n newsletterLabelTextCode\n newsletterSlug\n localizations(filters: { locale: { eq: $locale } }) {\n privacyNoticeUrl\n privacyNoticeDownloadUrl\n termsOfServiceUrl\n termsOfServiceDownloadUrl\n cancellationUrl\n emailIcon\n successActionButtonUrl\n successActionButtonLabel\n newsletterLabelTextCode\n newsletterSlug\n }\n }\n }\n }\n"): (typeof documents)["\n query PageContentForOffering($locale: String!, $apiIdentifier: String!) {\n offerings(\n filters: { apiIdentifier: { eq: $apiIdentifier } }\n pagination: { limit: 200 }\n ) {\n apiIdentifier\n stripeProductId\n defaultPurchase {\n purchaseDetails {\n details\n productName\n subtitle\n webIcon\n localizations(filters: { locale: { eq: $locale } }) {\n details\n productName\n subtitle\n webIcon\n }\n }\n }\n commonContent {\n privacyNoticeUrl\n privacyNoticeDownloadUrl\n termsOfServiceUrl\n termsOfServiceDownloadUrl\n cancellationUrl\n emailIcon\n successActionButtonUrl\n successActionButtonLabel\n newsletterLabelTextCode\n newsletterSlug\n localizations(filters: { locale: { eq: $locale } }) {\n privacyNoticeUrl\n privacyNoticeDownloadUrl\n termsOfServiceUrl\n termsOfServiceDownloadUrl\n cancellationUrl\n emailIcon\n successActionButtonUrl\n successActionButtonLabel\n newsletterLabelTextCode\n newsletterSlug\n }\n }\n }\n }\n"];
export function graphql(source: "\n query PageContentForOffering($locale: String!, $apiIdentifier: String!) {\n offerings(\n filters: { apiIdentifier: { eq: $apiIdentifier } }\n pagination: { limit: 200 }\n ) {\n apiIdentifier\n countries\n stripeProductId\n defaultPurchase {\n purchaseDetails {\n details\n productName\n subtitle\n webIcon\n localizations(filters: { locale: { eq: $locale } }) {\n details\n productName\n subtitle\n webIcon\n }\n }\n }\n commonContent {\n privacyNoticeUrl\n privacyNoticeDownloadUrl\n termsOfServiceUrl\n termsOfServiceDownloadUrl\n cancellationUrl\n emailIcon\n successActionButtonUrl\n successActionButtonLabel\n newsletterLabelTextCode\n newsletterSlug\n localizations(filters: { locale: { eq: $locale } }) {\n privacyNoticeUrl\n privacyNoticeDownloadUrl\n termsOfServiceUrl\n termsOfServiceDownloadUrl\n cancellationUrl\n emailIcon\n successActionButtonUrl\n successActionButtonLabel\n newsletterLabelTextCode\n newsletterSlug\n }\n }\n }\n }\n"): (typeof documents)["\n query PageContentForOffering($locale: String!, $apiIdentifier: String!) {\n offerings(\n filters: { apiIdentifier: { eq: $apiIdentifier } }\n pagination: { limit: 200 }\n ) {\n apiIdentifier\n countries\n stripeProductId\n defaultPurchase {\n purchaseDetails {\n details\n productName\n subtitle\n webIcon\n localizations(filters: { locale: { eq: $locale } }) {\n details\n productName\n subtitle\n webIcon\n }\n }\n }\n commonContent {\n privacyNoticeUrl\n privacyNoticeDownloadUrl\n termsOfServiceUrl\n termsOfServiceDownloadUrl\n cancellationUrl\n emailIcon\n successActionButtonUrl\n successActionButtonLabel\n newsletterLabelTextCode\n newsletterSlug\n localizations(filters: { locale: { eq: $locale } }) {\n privacyNoticeUrl\n privacyNoticeDownloadUrl\n termsOfServiceUrl\n termsOfServiceDownloadUrl\n cancellationUrl\n emailIcon\n successActionButtonUrl\n successActionButtonLabel\n newsletterLabelTextCode\n newsletterSlug\n }\n }\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/

4
libs/shared/cms/src/__generated__/graphql.ts сгенерированный

Различия файлов скрыты, потому что одна или несколько строк слишком длинны

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

@ -47,6 +47,7 @@ export const PageContentOfferingResultFactory = (
override?: Partial<PageContentOfferingResult>
): PageContentOfferingResult => ({
apiIdentifier: faker.string.sample(),
countries: [faker.string.sample()],
stripeProductId: faker.string.sample(),
defaultPurchase: PageContentOfferingDefaultPurchaseResultFactory(),
commonContent: {

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

@ -11,6 +11,7 @@ export const pageContentForOfferingQuery = graphql(`
pagination: { limit: 200 }
) {
apiIdentifier
countries
stripeProductId
defaultPurchase {
purchaseDetails {

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

@ -46,6 +46,7 @@ export interface PageContentOfferingTransformed
export interface PageContentOfferingResult {
apiIdentifier: string;
countries: string[];
stripeProductId: string;
defaultPurchase: PageContentOfferingDefaultPurchaseResult;
commonContent: PageContentCommonContentResult & {