diff --git a/.changeset/address-telephone-required-from-magento.md b/.changeset/address-telephone-required-from-magento.md new file mode 100644 index 00000000000..ce86289afd3 --- /dev/null +++ b/.changeset/address-telephone-required-from-magento.md @@ -0,0 +1,17 @@ +--- +'@graphcommerce/magento-cart-shipping-address': patch +'@graphcommerce/magento-customer': patch +'@graphcommerce/magento-cart': patch +--- + +Address forms now honor Magento's `customer/address/telephone_show` configuration instead of silently submitting a `000 - 000 0000` placeholder telephone. + +`CartAddressInput.telephone` is a non-nullable `String!`, so the address forms always had to send a value; because the mutation variable is declared as an optional `String`, `required.telephone` resolved to `false` and the field rendered as optional, after which `onBeforeSubmit` substituted the placeholder. Every checkout that skipped the field therefore stored a fake phone number on the order. + +Magento does expose whether a telephone is required — `attributesForm(formCode: "customer_address_edit")` returns `is_required` per address attribute and reflects `customer/address/telephone_show`. `ShippingAddressForm`, `EditBillingAddressForm` and `EditAddressForm` now read that metadata through the existing `useAttributesForm` hook and mark the field required accordingly. The placeholder is gone: an empty telephone is submitted as an empty string, which Magento validates against the very same `is_required` (`Magento\Customer\Model\Address\Validator\General::checkOptionalFields`). + +This is a deliberate behavior change. Where a customer who bypassed the client-side validation previously got a successful order carrying a fake phone number, a shop that requires a telephone now gets a proper `"telephone" is required. Enter and try again.` validation error. Addresses that still carry the old placeholder are cleared when they are loaded into a form, so the customer fills in a real number instead of being shown zeroes. + +`attributesForm` only exists since Magento 2.4.7, so all of this is version-gated in `stripLegacyPlaceholderTelephone()` / `applyLegacyPlaceholderTelephone()` (`@graphcommerce/magento-customer`): below 2.4.7 the placeholder is still submitted and no longer stripped, exactly as before. + +Pages that render these forms should preload the metadata in `getStaticProps` with `await preloadAttributesForm(client, 'customer_address_edit')`, the same way the account pages already preload `customer_account_create` / `customer_account_edit`. The examples do this for the checkout and address pages, which also closes the window where a customer could submit before the metadata resolved. diff --git a/examples/magento-graphcms/pages/account/addresses/edit.tsx b/examples/magento-graphcms/pages/account/addresses/edit.tsx index 7f2fc6ed1e2..f362cbdfd83 100644 --- a/examples/magento-graphcms/pages/account/addresses/edit.tsx +++ b/examples/magento-graphcms/pages/account/addresses/edit.tsx @@ -6,7 +6,8 @@ import { AccountDashboardAddressesDocument, getCustomerAccountIsDisabled, } from '@graphcommerce/magento-customer' -import { PageMeta, StoreConfigDocument } from '@graphcommerce/magento-store' +import { PageMeta, preloadAttributesForm, StoreConfigDocument } from '@graphcommerce/magento-store' +import { magentoVersion } from '@graphcommerce/next-config/config' import { GetStaticProps, iconAddresses, @@ -82,6 +83,8 @@ export const getStaticProps: GetPageStaticProps = async (context) => { const up = { href: '/account/addresses', title: t`Addresses` } const conf = client.query({ query: StoreConfigDocument }) + if (magentoVersion >= 247) await preloadAttributesForm(client, 'customer_address_edit') + return { props: { apolloState: await conf.then(() => client.cache.extract()), diff --git a/examples/magento-graphcms/pages/checkout/customer/addresses/edit.tsx b/examples/magento-graphcms/pages/checkout/customer/addresses/edit.tsx index ae2f2685d61..2354be8bb62 100644 --- a/examples/magento-graphcms/pages/checkout/customer/addresses/edit.tsx +++ b/examples/magento-graphcms/pages/checkout/customer/addresses/edit.tsx @@ -7,7 +7,8 @@ import { AccountDashboardAddressesDocument, getCustomerAccountIsDisabled, } from '@graphcommerce/magento-customer' -import { PageMeta, StoreConfigDocument } from '@graphcommerce/magento-store' +import { PageMeta, preloadAttributesForm, StoreConfigDocument } from '@graphcommerce/magento-store' +import { magentoVersion } from '@graphcommerce/next-config/config' import { GetStaticProps, iconAddresses, @@ -98,6 +99,8 @@ export const getStaticProps: GetPageStaticProps = async (context) => { const up = { href: '/checkout', title: t`Shipping` } const conf = client.query({ query: StoreConfigDocument }) + if (magentoVersion >= 247) await preloadAttributesForm(client, 'customer_address_edit') + return { props: { apolloState: await conf.then(() => client.cache.extract()), diff --git a/examples/magento-graphcms/pages/checkout/edit/billing-address.tsx b/examples/magento-graphcms/pages/checkout/edit/billing-address.tsx index 98b53e7c3b8..95642cd5e2c 100644 --- a/examples/magento-graphcms/pages/checkout/edit/billing-address.tsx +++ b/examples/magento-graphcms/pages/checkout/edit/billing-address.tsx @@ -1,7 +1,8 @@ import { PageOptions } from '@graphcommerce/framer-next-pages' import { cacheFirst } from '@graphcommerce/graphql' import { getCheckoutIsDisabled, EditBillingAddressForm } from '@graphcommerce/magento-cart' -import { StoreConfigDocument } from '@graphcommerce/magento-store' +import { preloadAttributesForm, StoreConfigDocument } from '@graphcommerce/magento-store' +import { magentoVersion } from '@graphcommerce/next-config/config' import { GetStaticProps, PageMeta, LayoutOverlayHeader, LayoutTitle } from '@graphcommerce/next-ui' import { t } from '@lingui/core/macro' import { Trans } from '@lingui/react/macro' @@ -61,6 +62,8 @@ export const getStaticProps: GetPageStaticProps = async (context) => { fetchPolicy: cacheFirst(staticClient), }) + if (magentoVersion >= 247) await preloadAttributesForm(client, 'customer_address_edit') + return { props: { ...(await layout).data, diff --git a/examples/magento-graphcms/pages/checkout/index.tsx b/examples/magento-graphcms/pages/checkout/index.tsx index b8fcc5169c8..03fe1bdbdf7 100644 --- a/examples/magento-graphcms/pages/checkout/index.tsx +++ b/examples/magento-graphcms/pages/checkout/index.tsx @@ -22,7 +22,8 @@ import { } from '@graphcommerce/magento-cart-shipping-address' import { ShippingMethodForm } from '@graphcommerce/magento-cart-shipping-method' import { CustomerDocument, useCustomerQuery } from '@graphcommerce/magento-customer' -import { PageMeta, StoreConfigDocument } from '@graphcommerce/magento-store' +import { PageMeta, preloadAttributesForm, StoreConfigDocument } from '@graphcommerce/magento-store' +import { magentoVersion } from '@graphcommerce/next-config/config' import { FormActions, GetStaticProps, @@ -163,6 +164,8 @@ export const getStaticProps: GetPageStaticProps = async (context) => { fetchPolicy: cacheFirst(staticClient), }) + if (magentoVersion >= 247) await preloadAttributesForm(client, 'customer_address_edit') + return { props: { ...(await layout).data, diff --git a/examples/magento-open-source/pages/account/addresses/edit.tsx b/examples/magento-open-source/pages/account/addresses/edit.tsx index 24fc985781d..a7f159a3973 100644 --- a/examples/magento-open-source/pages/account/addresses/edit.tsx +++ b/examples/magento-open-source/pages/account/addresses/edit.tsx @@ -6,7 +6,8 @@ import { useCustomerQuery, WaitForCustomer, } from '@graphcommerce/magento-customer' -import { PageMeta, StoreConfigDocument } from '@graphcommerce/magento-store' +import { PageMeta, preloadAttributesForm, StoreConfigDocument } from '@graphcommerce/magento-store' +import { magentoVersion } from '@graphcommerce/next-config/config' import type { GetStaticProps } from '@graphcommerce/next-ui' import { iconAddresses, @@ -83,6 +84,8 @@ export const getStaticProps: GetPageStaticProps = async (context) => { const up = { href: '/account/addresses', title: t`Addresses` } const conf = client.query({ query: StoreConfigDocument }) + if (magentoVersion >= 247) await preloadAttributesForm(client, 'customer_address_edit') + return { props: { apolloState: await conf.then(() => client.cache.extract()), diff --git a/examples/magento-open-source/pages/checkout/customer/addresses/edit.tsx b/examples/magento-open-source/pages/checkout/customer/addresses/edit.tsx index b5fd0e8e79f..07905276437 100644 --- a/examples/magento-open-source/pages/checkout/customer/addresses/edit.tsx +++ b/examples/magento-open-source/pages/checkout/customer/addresses/edit.tsx @@ -7,7 +7,8 @@ import { getCustomerAccountIsDisabled, useCustomerQuery, } from '@graphcommerce/magento-customer' -import { PageMeta, StoreConfigDocument } from '@graphcommerce/magento-store' +import { PageMeta, preloadAttributesForm, StoreConfigDocument } from '@graphcommerce/magento-store' +import { magentoVersion } from '@graphcommerce/next-config/config' import type { GetStaticProps } from '@graphcommerce/next-ui' import { FullPageMessage, @@ -99,6 +100,8 @@ export const getStaticProps: GetPageStaticProps = async (context) => { const up = { href: '/checkout', title: t`Shipping` } const conf = client.query({ query: StoreConfigDocument }) + if (magentoVersion >= 247) await preloadAttributesForm(client, 'customer_address_edit') + return { props: { apolloState: await conf.then(() => client.cache.extract()), diff --git a/examples/magento-open-source/pages/checkout/edit/billing-address.tsx b/examples/magento-open-source/pages/checkout/edit/billing-address.tsx index 507b268e37e..c319ec3e435 100644 --- a/examples/magento-open-source/pages/checkout/edit/billing-address.tsx +++ b/examples/magento-open-source/pages/checkout/edit/billing-address.tsx @@ -2,7 +2,8 @@ import type { PageOptions } from '@graphcommerce/framer-next-pages' import { cacheFirst } from '@graphcommerce/graphql' import { EditBillingAddressForm, getCheckoutIsDisabled } from '@graphcommerce/magento-cart' import { getBillingAddressPermission } from '@graphcommerce/magento-customer' -import { StoreConfigDocument } from '@graphcommerce/magento-store' +import { preloadAttributesForm, StoreConfigDocument } from '@graphcommerce/magento-store' +import { magentoVersion } from '@graphcommerce/next-config/config' import type { GetStaticProps } from '@graphcommerce/next-ui' import { LayoutOverlayHeader, LayoutTitle, PageMeta } from '@graphcommerce/next-ui' import { t } from '@lingui/core/macro' @@ -63,6 +64,8 @@ export const getStaticProps: GetPageStaticProps = async (context) => { fetchPolicy: cacheFirst(staticClient), }) + if (magentoVersion >= 247) await preloadAttributesForm(client, 'customer_address_edit') + return { props: { ...(await layout).data, diff --git a/examples/magento-open-source/pages/checkout/index.tsx b/examples/magento-open-source/pages/checkout/index.tsx index b224f7bde5d..cf566311bd9 100644 --- a/examples/magento-open-source/pages/checkout/index.tsx +++ b/examples/magento-open-source/pages/checkout/index.tsx @@ -22,7 +22,8 @@ import { } from '@graphcommerce/magento-cart-shipping-address' import { ShippingMethodForm } from '@graphcommerce/magento-cart-shipping-method' import { CustomerDocument, useCustomerQuery } from '@graphcommerce/magento-customer' -import { PageMeta, StoreConfigDocument } from '@graphcommerce/magento-store' +import { PageMeta, preloadAttributesForm, StoreConfigDocument } from '@graphcommerce/magento-store' +import { magentoVersion } from '@graphcommerce/next-config/config' import type { GetStaticProps } from '@graphcommerce/next-ui' import { FormActions, @@ -164,6 +165,8 @@ export const getStaticProps: GetPageStaticProps = async (context) => { fetchPolicy: cacheFirst(staticClient), }) + if (magentoVersion >= 247) await preloadAttributesForm(client, 'customer_address_edit') + return { props: { ...(await layout).data, diff --git a/examples/magento-storyblok/pages/account/addresses/edit.tsx b/examples/magento-storyblok/pages/account/addresses/edit.tsx index 24fc985781d..a7f159a3973 100644 --- a/examples/magento-storyblok/pages/account/addresses/edit.tsx +++ b/examples/magento-storyblok/pages/account/addresses/edit.tsx @@ -6,7 +6,8 @@ import { useCustomerQuery, WaitForCustomer, } from '@graphcommerce/magento-customer' -import { PageMeta, StoreConfigDocument } from '@graphcommerce/magento-store' +import { PageMeta, preloadAttributesForm, StoreConfigDocument } from '@graphcommerce/magento-store' +import { magentoVersion } from '@graphcommerce/next-config/config' import type { GetStaticProps } from '@graphcommerce/next-ui' import { iconAddresses, @@ -83,6 +84,8 @@ export const getStaticProps: GetPageStaticProps = async (context) => { const up = { href: '/account/addresses', title: t`Addresses` } const conf = client.query({ query: StoreConfigDocument }) + if (magentoVersion >= 247) await preloadAttributesForm(client, 'customer_address_edit') + return { props: { apolloState: await conf.then(() => client.cache.extract()), diff --git a/examples/magento-storyblok/pages/checkout/customer/addresses/edit.tsx b/examples/magento-storyblok/pages/checkout/customer/addresses/edit.tsx index b5fd0e8e79f..07905276437 100644 --- a/examples/magento-storyblok/pages/checkout/customer/addresses/edit.tsx +++ b/examples/magento-storyblok/pages/checkout/customer/addresses/edit.tsx @@ -7,7 +7,8 @@ import { getCustomerAccountIsDisabled, useCustomerQuery, } from '@graphcommerce/magento-customer' -import { PageMeta, StoreConfigDocument } from '@graphcommerce/magento-store' +import { PageMeta, preloadAttributesForm, StoreConfigDocument } from '@graphcommerce/magento-store' +import { magentoVersion } from '@graphcommerce/next-config/config' import type { GetStaticProps } from '@graphcommerce/next-ui' import { FullPageMessage, @@ -99,6 +100,8 @@ export const getStaticProps: GetPageStaticProps = async (context) => { const up = { href: '/checkout', title: t`Shipping` } const conf = client.query({ query: StoreConfigDocument }) + if (magentoVersion >= 247) await preloadAttributesForm(client, 'customer_address_edit') + return { props: { apolloState: await conf.then(() => client.cache.extract()), diff --git a/examples/magento-storyblok/pages/checkout/edit/billing-address.tsx b/examples/magento-storyblok/pages/checkout/edit/billing-address.tsx index 507b268e37e..c319ec3e435 100644 --- a/examples/magento-storyblok/pages/checkout/edit/billing-address.tsx +++ b/examples/magento-storyblok/pages/checkout/edit/billing-address.tsx @@ -2,7 +2,8 @@ import type { PageOptions } from '@graphcommerce/framer-next-pages' import { cacheFirst } from '@graphcommerce/graphql' import { EditBillingAddressForm, getCheckoutIsDisabled } from '@graphcommerce/magento-cart' import { getBillingAddressPermission } from '@graphcommerce/magento-customer' -import { StoreConfigDocument } from '@graphcommerce/magento-store' +import { preloadAttributesForm, StoreConfigDocument } from '@graphcommerce/magento-store' +import { magentoVersion } from '@graphcommerce/next-config/config' import type { GetStaticProps } from '@graphcommerce/next-ui' import { LayoutOverlayHeader, LayoutTitle, PageMeta } from '@graphcommerce/next-ui' import { t } from '@lingui/core/macro' @@ -63,6 +64,8 @@ export const getStaticProps: GetPageStaticProps = async (context) => { fetchPolicy: cacheFirst(staticClient), }) + if (magentoVersion >= 247) await preloadAttributesForm(client, 'customer_address_edit') + return { props: { ...(await layout).data, diff --git a/examples/magento-storyblok/pages/checkout/index.tsx b/examples/magento-storyblok/pages/checkout/index.tsx index 8e6ce6d00a3..a9c4a21621e 100644 --- a/examples/magento-storyblok/pages/checkout/index.tsx +++ b/examples/magento-storyblok/pages/checkout/index.tsx @@ -22,7 +22,8 @@ import { } from '@graphcommerce/magento-cart-shipping-address' import { ShippingMethodForm } from '@graphcommerce/magento-cart-shipping-method' import { CustomerDocument, useCustomerQuery } from '@graphcommerce/magento-customer' -import { PageMeta, StoreConfigDocument } from '@graphcommerce/magento-store' +import { PageMeta, preloadAttributesForm, StoreConfigDocument } from '@graphcommerce/magento-store' +import { magentoVersion } from '@graphcommerce/next-config/config' import type { GetStaticProps } from '@graphcommerce/next-ui' import { FormActions, @@ -166,6 +167,8 @@ export const getStaticProps: GetPageStaticProps = async (context) => { }) const globalConfig = fetchGlobalConfig(context) + if (magentoVersion >= 247) await preloadAttributesForm(client, 'customer_address_edit') + return { props: { ...(await layout).data, diff --git a/packages/magento-cart-shipping-address/components/ShippingAddressForm/ShippingAddressForm.tsx b/packages/magento-cart-shipping-address/components/ShippingAddressForm/ShippingAddressForm.tsx index 36fa5f11ce8..12bf35ca341 100644 --- a/packages/magento-cart-shipping-address/components/ShippingAddressForm/ShippingAddressForm.tsx +++ b/packages/magento-cart-shipping-address/components/ShippingAddressForm/ShippingAddressForm.tsx @@ -16,13 +16,19 @@ import { import type { CartAddressFragment } from '@graphcommerce/magento-cart' import { AddressFields, + applyLegacyPlaceholderTelephone, CompanyFields, CustomerDocument, NameFields, + stripLegacyPlaceholderTelephone, useBillingAddressPermission, useCustomerQuery, } from '@graphcommerce/magento-customer' -import { CountryRegionsDocument, StoreConfigDocument } from '@graphcommerce/magento-store' +import { + CountryRegionsDocument, + StoreConfigDocument, + useAttributesForm, +} from '@graphcommerce/magento-store' import { customerAddressNoteEnable } from '@graphcommerce/next-config/config' import { Form, FormRow } from '@graphcommerce/next-ui' import { Trans } from '@lingui/react/macro' @@ -57,6 +63,11 @@ export const ShippingAddressForm = React.memo((props) const billingAddressReadonly = useBillingAddressPermission() === 'READONLY' + // Magento's address attribute metadata tells us whether a telephone is required, as configured by + // `customer/address/telephone_show`. + const addressAttributes = useAttributesForm({ formCode: 'customer_address_edit' }) + const telephoneRequired = addressAttributes.find((a) => a.code === 'telephone')?.is_required + const shopCountry = config?.storeConfig?.locale?.split('_')?.[1].toUpperCase() const shippingAddress = cartQuery?.cart?.shipping_addresses?.[0] @@ -102,8 +113,7 @@ export const ShippingAddressForm = React.memo((props) // todo(paales): change to something more sustainable firstname: currentAddress?.firstname ?? customerQuery?.customer?.firstname ?? '', lastname: currentAddress?.lastname ?? customerQuery?.customer?.lastname ?? '', - telephone: - currentAddress?.telephone !== '000 - 000 0000' ? currentAddress?.telephone : '', + telephone: stripLegacyPlaceholderTelephone(currentAddress?.telephone), city: currentAddress?.city ?? '', company: currentAddress?.company ?? '', vatId: currentAddress?.vat_id ?? '', @@ -130,7 +140,11 @@ export const ShippingAddressForm = React.memo((props) return { ...variables, - telephone: variables.telephone || '000 - 000 0000', + // `CartAddressInput.telephone` is non-nullable, so an empty string is sent rather than + // nothing. Magento validates it against the same `is_required` we render the field with, so + // a shop that requires a telephone returns a proper validation error instead of silently + // accepting a fake number. + telephone: applyLegacyPlaceholderTelephone(variables.telephone), region: regionId ? variables.region : '', regionId, addition: variables.addition ?? '', @@ -159,7 +173,7 @@ export const ShippingAddressForm = React.memo((props) control={form.control} name='telephone' variant='outlined' - required={required.telephone} + required={required.telephone || telephoneRequired === true} showValid /> diff --git a/packages/magento-cart/components/EditBillingAddressForm/EditBillingAddressForm.tsx b/packages/magento-cart/components/EditBillingAddressForm/EditBillingAddressForm.tsx index 2a4d5e9ee66..6c90af12688 100644 --- a/packages/magento-cart/components/EditBillingAddressForm/EditBillingAddressForm.tsx +++ b/packages/magento-cart/components/EditBillingAddressForm/EditBillingAddressForm.tsx @@ -8,10 +8,12 @@ import { import { AddressFields, ApolloCustomerErrorAlert, + applyLegacyPlaceholderTelephone, CompanyFields, NameFields, + stripLegacyPlaceholderTelephone, } from '@graphcommerce/magento-customer' -import { CountryRegionsDocument } from '@graphcommerce/magento-store' +import { CountryRegionsDocument, useAttributesForm } from '@graphcommerce/magento-store' import { Button, Form, FormActions, FormDivider, FormRow } from '@graphcommerce/next-ui' import { Trans } from '@lingui/react/macro' import type { SxProps, Theme } from '@mui/material' @@ -28,6 +30,11 @@ export function EditBillingAddressForm(props: EditBillingAddressFormProps) { const goToCheckout = useHistoryGo({ href: '/checkout/payment' }) + // Magento's address attribute metadata tells us whether a telephone is required, as configured by + // `customer/address/telephone_show`. + const addressAttributes = useAttributesForm({ formCode: 'customer_address_edit' }) + const telephoneRequired = addressAttributes.find((a) => a.code === 'telephone')?.is_required + const form = useFormGqlMutationCart(SetBillingAddressDocument, { defaultValues: { firstname: address?.firstname, @@ -36,7 +43,7 @@ export function EditBillingAddressForm(props: EditBillingAddressFormProps) { city: address?.city, countryCode: address?.country.code, street: address?.street?.[0] ?? '', - telephone: address?.telephone, + telephone: stripLegacyPlaceholderTelephone(address?.telephone), houseNumber: address?.street?.[1] ?? '', addition: address?.street?.[2] ?? '', company: address?.company ?? '', @@ -56,7 +63,9 @@ export function EditBillingAddressForm(props: EditBillingAddressFormProps) { return { ...variables, - telephone: variables.telephone || '000 - 000 0000', + // See ShippingAddressForm: `CartAddressInput.telephone` is non-nullable, so send an empty + // string rather than a fake number and let Magento validate it. + telephone: applyLegacyPlaceholderTelephone(variables.telephone), regionId, } }, @@ -77,7 +86,7 @@ export function EditBillingAddressForm(props: EditBillingAddressFormProps) { a.code === 'telephone')?.is_required + const form = useFormGqlMutation( UpdateCustomerAddressDocument, { @@ -39,7 +45,7 @@ export function EditAddressForm(props: EditAddressFormProps) { postcode: address?.postcode, city: address?.city, countryCode: address?.country_code, - telephone: address?.telephone, + telephone: stripLegacyPlaceholderTelephone(address?.telephone), houseNumber: address?.street?.[1] ?? '', addition: address?.street?.[2] ?? '', region: address?.region, @@ -92,7 +98,7 @@ export function EditAddressForm(props: EditAddressFormProps) { = 247 + +/** + * Reading direction: an address saved before this behavior changed still carries the placeholder. + * Clear it so the customer is asked for a real number instead of being shown zeroes. + * + * On Magento < 2.4.7 the placeholder is still actively submitted, so the value is left alone. + */ +export function stripLegacyPlaceholderTelephone( + telephone: T, +): T | '' { + if (!hasAddressAttributeMetadata) return telephone + return telephone === legacyPlaceholderTelephone ? '' : telephone +} + +/** + * Writing direction: submit an empty string for an empty field and let Magento validate it against + * the same `is_required` the form renders with. + * + * On Magento < 2.4.7 that flag can't be read, so the placeholder is submitted as before — otherwise + * a shop that requires a telephone would start rejecting a form that never marked the field + * required. + */ +export function applyLegacyPlaceholderTelephone(telephone: string | null | undefined): string { + if (!hasAddressAttributeMetadata) return telephone || legacyPlaceholderTelephone + return telephone || '' +}