From 18ef8ef45d244aa480508db9377f4fa83e651734 Mon Sep 17 00:00:00 2001 From: Hassan Goodarzi Date: Sat, 25 Jul 2026 10:29:35 +0330 Subject: [PATCH 1/6] feat(address-autocomplete): add Google Places address autocomplete package --- .changeset/lucky-games-fix.md | 5 + packages/address-autocomplete/Config.graphqls | 6 + packages/address-autocomplete/README.md | 103 ++++++++++++ .../components/AddressAutocomplete.test.tsx | 157 ++++++++++++++++++ .../components/AddressAutocomplete.tsx | 136 +++++++++++++++ .../hooks/useCountries.ts | 9 + packages/address-autocomplete/index.ts | 5 + packages/address-autocomplete/package.json | 38 +++++ .../AddAddressAutocompleteAddressFields.tsx | 19 +++ packages/address-autocomplete/tsconfig.json | 9 + .../utils/addressValues.test.ts | 38 +++++ .../utils/addressValues.ts | 18 ++ .../utils/findRegionId.test.ts | 43 +++++ .../utils/findRegionId.ts | 42 +++++ .../utils/formatAddress.test.ts | 73 ++++++++ .../utils/formatAddress.ts | 68 ++++++++ 16 files changed, 769 insertions(+) create mode 100644 .changeset/lucky-games-fix.md create mode 100644 packages/address-autocomplete/Config.graphqls create mode 100644 packages/address-autocomplete/README.md create mode 100644 packages/address-autocomplete/components/AddressAutocomplete.test.tsx create mode 100644 packages/address-autocomplete/components/AddressAutocomplete.tsx create mode 100644 packages/address-autocomplete/hooks/useCountries.ts create mode 100644 packages/address-autocomplete/index.ts create mode 100644 packages/address-autocomplete/package.json create mode 100644 packages/address-autocomplete/plugins/AddAddressAutocompleteAddressFields.tsx create mode 100644 packages/address-autocomplete/tsconfig.json create mode 100644 packages/address-autocomplete/utils/addressValues.test.ts create mode 100644 packages/address-autocomplete/utils/addressValues.ts create mode 100644 packages/address-autocomplete/utils/findRegionId.test.ts create mode 100644 packages/address-autocomplete/utils/findRegionId.ts create mode 100644 packages/address-autocomplete/utils/formatAddress.test.ts create mode 100644 packages/address-autocomplete/utils/formatAddress.ts diff --git a/.changeset/lucky-games-fix.md b/.changeset/lucky-games-fix.md new file mode 100644 index 0000000000..7985633767 --- /dev/null +++ b/.changeset/lucky-games-fix.md @@ -0,0 +1,5 @@ +--- +'@graphcommerce/address-autocomplete': minor +--- + +Adds Google Places address autocomplete to customer and checkout address forms. Selected addresses populate GraphCommerce address fields and Magento regions, with automatic fallback to manual entry when Google Maps is unavailable. diff --git a/packages/address-autocomplete/Config.graphqls b/packages/address-autocomplete/Config.graphqls new file mode 100644 index 0000000000..5d24b50a64 --- /dev/null +++ b/packages/address-autocomplete/Config.graphqls @@ -0,0 +1,6 @@ +extend input GraphCommerceConfig { + """ + Google Maps API key for address autocomplete. This is required for the address autocomplete to work. + """ + googleMapsApiKey: String +} diff --git a/packages/address-autocomplete/README.md b/packages/address-autocomplete/README.md new file mode 100644 index 0000000000..75e7285efb --- /dev/null +++ b/packages/address-autocomplete/README.md @@ -0,0 +1,103 @@ +# @graphcommerce/address-autocomplete + +Adds Google Places address autocomplete to GraphCommerce customer and checkout +address forms. + +When a customer selects an address, the package fills the street, house number, +addition, postcode, city, country, and Magento region fields. If Google Maps is +not configured or cannot be loaded, the standard manual street field remains +available. + +## Installation + +Install the package using the same version as the other GraphCommerce packages +in your project: + +```bash +yarn add @graphcommerce/address-autocomplete +``` + +Run code generation after installing the package: + +```bash +yarn codegen +``` + +The package uses the GraphCommerce plugin system, so no component overrides are +required. + +## Google Maps setup + +Create a Google Maps API key with the Places API (New) enabled. Restrict the key +to the domains where the storefront is hosted. + +Add the key to `graphcommerce.config.ts`: + +```ts +import type { GraphCommerceConfig } from '@graphcommerce/next-config' + +const config: Partial = { + googleMapsApiKey: process.env.GOOGLE_MAPS_API_KEY, +} + +export default config +``` + +Restart code generation and the development server after changing the +configuration. + +See [Config.graphqls](./Config.graphqls) for the available configuration fields. + +## Behavior + +The plugin replaces the standard `AddressStreet` field only when +`googleMapsApiKey` is configured. It: + +- loads the Google Maps Places library; +- limits suggestions to addresses; +- uses the browser's preferred language for suggestions; +- maps Google address components to GraphCommerce address fields; +- resolves the Google region to the corresponding Magento region ID; +- preserves the standard field styling and validation behavior; +- suppresses browser address autofill from competing with Google suggestions; + and +- falls back to the original manual field if the API is unavailable. + +## Public API + +The main package export provides: + +- `AddressAutocomplete` — Google-assisted street field with a required fallback; +- `useCountries` — loads Magento countries and regions; +- `formatAddress` — converts Google address components to a normalized address; +- `addressValues` — converts a normalized address to form values; and +- `findRegionId` — matches a Google region to a Magento region ID. + +The GraphCommerce plugin is available from: + +```ts +@graphcommerce/address-autocomplete/plugins/AddAddressAutocompleteAddressFields +``` + +It is discovered automatically during code generation and normally does not need +to be imported directly. + +## Troubleshooting + +### Suggestions are not displayed + +Check that: + +- the global `googleMapsApiKey` is configured; +- the Places API (New) is enabled for the Google Cloud project; +- the API key allows the storefront domain; and +- billing is enabled for the Google Cloud project. + +The manual street field is displayed when the key is missing or the Google Maps +script cannot be loaded. + +### A selected region is not filled + +The package matches regions by country, region code, and normalized region name. +Verify that the region exists in Magento and that its code or name corresponds +to the value returned by Google. diff --git a/packages/address-autocomplete/components/AddressAutocomplete.test.tsx b/packages/address-autocomplete/components/AddressAutocomplete.test.tsx new file mode 100644 index 0000000000..d7f3ec21c8 --- /dev/null +++ b/packages/address-autocomplete/components/AddressAutocomplete.test.tsx @@ -0,0 +1,157 @@ +// @vitest-environment happy-dom + +import { act } from 'react' +import type { Root } from 'react-dom/client' +import { createRoot } from 'react-dom/client' +import { AddressAutocomplete } from './AddressAutocomplete' + +const { loaderOptions, loaderState } = vi.hoisted(() => ({ + loaderOptions: [] as unknown[], + loaderState: { + isLoaded: false, + loadError: new Error('Maps failed') as Error | undefined, + }, +})) +const { autocompleteOptions } = vi.hoisted(() => ({ + autocompleteOptions: [] as unknown[], +})) + +vi.mock('@graphcommerce/ecommerce-ui', () => ({ + TextFieldElement: ({ + inputProps, + name, + variant, + }: { + inputProps?: React.InputHTMLAttributes + name: string + variant: string + }) => , +})) + +vi.mock('@graphcommerce/magento-customer', () => ({ + useAddressFieldsForm: () => ({ + control: {}, + getValues: () => '', + name: { + street: 'street', + houseNumber: 'houseNumber', + addition: 'addition', + postcode: 'postcode', + city: 'city', + countryCode: 'countryCode', + regionId: 'regionId', + }, + readOnly: false, + required: { street: true }, + setValue: vi.fn(), + }), +})) + +vi.mock('@graphcommerce/next-config/config', () => ({ + googleMapsApiKey: 'test-key', +})) + +vi.mock('@lingui/core/macro', () => ({ + t: (strings: TemplateStringsArray) => strings.join(''), +})) + +vi.mock('@lingui/react/macro', () => ({ + Trans: ({ children }: { children: React.ReactNode }) => children, +})) + +vi.mock('@graphcommerce/next-ui', () => ({ + ErrorSnackbar: ({ children, open }: { children: React.ReactNode; open: boolean }) => + open ?
{children}
: null, +})) + +vi.mock('@react-google-maps/api', () => ({ + Autocomplete: ({ children, options }: { children: React.ReactNode; options: unknown }) => { + autocompleteOptions.push(options) + return
{children}
+ }, + useLoadScript: (options: unknown) => { + loaderOptions.push(options) + return loaderState + }, +})) + +vi.mock('../hooks/useCountries', () => ({ + useCountries: () => undefined, +})) + +afterEach(() => { + act(() => testRoot?.unmount()) + testContainer?.remove() + testRoot = undefined + testContainer = undefined + loaderState.isLoaded = false + loaderState.loadError = new Error('Maps failed') + loaderOptions.length = 0 + autocompleteOptions.length = 0 + vi.clearAllMocks() +}) + +let testRoot: Root | undefined +let testContainer: HTMLDivElement | undefined + +beforeAll(() => { + Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }) +}) + +afterAll(() => { + Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT') +}) + +describe('AddressAutocomplete', () => { + it('keeps manual street entry available when Google Maps fails', () => { + testContainer = document.createElement('div') + document.body.appendChild(testContainer) + testRoot = createRoot(testContainer) + + act(() => { + testRoot?.render( + Manual street input} + />, + ) + }) + + expect(testContainer.textContent).toContain('Manual street input') + expect(testContainer.textContent).toContain( + 'Address search is unavailable. You can enter the address manually.', + ) + }) + + it('uses the standard styled street field after Google Maps loads', () => { + loaderState.isLoaded = true + loaderState.loadError = undefined + testContainer = document.createElement('div') + document.body.appendChild(testContainer) + testRoot = createRoot(testContainer) + + act(() => { + testRoot?.render( + Manual street input} + />, + ) + }) + + const streetInput = testContainer.querySelector('input[aria-label="Street"]') + expect(streetInput).not.toBeNull() + expect(streetInput?.dataset.variant).toBe('outlined') + expect(streetInput?.getAttribute('autocomplete')).toBe('one-time-code') + expect(streetInput?.name).toMatch(/^places-search-/) + expect(testContainer.querySelector('[data-google-autocomplete]')).not.toBeNull() + expect(loaderOptions.at(-1)).toEqual({ + googleMapsApiKey: 'test-key', + libraries: ['places'], + }) + expect(autocompleteOptions.at(-1)).toEqual({ + fields: ['address_components'], + types: ['address'], + }) + }) +}) diff --git a/packages/address-autocomplete/components/AddressAutocomplete.tsx b/packages/address-autocomplete/components/AddressAutocomplete.tsx new file mode 100644 index 0000000000..95aa2f7e05 --- /dev/null +++ b/packages/address-autocomplete/components/AddressAutocomplete.tsx @@ -0,0 +1,136 @@ +import { TextFieldElement } from '@graphcommerce/ecommerce-ui' +import type { FieldPath, FieldValues, PathValue } from '@graphcommerce/ecommerce-ui' +import type { AddressFieldsOptions } from '@graphcommerce/magento-customer' +import { useAddressFieldsForm } from '@graphcommerce/magento-customer' +import { googleMapsApiKey } from '@graphcommerce/next-config/config' +import { ErrorSnackbar } from '@graphcommerce/next-ui' +import { Trans } from '@lingui/react/macro' +import { + Autocomplete, + useLoadScript, + type AutocompleteProps, + type Libraries, +} from '@react-google-maps/api' +import type { ReactNode } from 'react' +import { useCallback, useEffect, useId, useRef, useState } from 'react' +import { useCountries } from '../hooks/useCountries' +import { addressValues } from '../utils/addressValues' +import type { FormattedAddress } from '../utils/formatAddress' +import { formatAddress } from '../utils/formatAddress' + +const libraries: Libraries = ['places'] +const autocompleteOptions: AutocompleteProps['options'] = { + fields: ['address_components'], + types: ['address'], +} +const updateOptions = { + shouldDirty: true, + shouldTouch: true, + shouldValidate: true, +} as const + +export type AddressAutocompleteProps< + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath, +> = AddressFieldsOptions & { + fallback: ReactNode +} + +export function AddressAutocomplete< + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath, +>(options: AddressAutocompleteProps) { + const { fallback } = options + const form = useAddressFieldsForm(options) + const { control, getValues, name, readOnly, required, setValue } = form + const [autocomplete, setAutocomplete] = useState(null) + const pendingRegion = useRef(null) + const addressSearchName = `places-search-${useId()}` + const countries = useCountries() + const { isLoaded, loadError } = useLoadScript({ + googleMapsApiKey: googleMapsApiKey ?? '', + libraries, + }) + + const updateValue = useCallback( + (fieldName: TName, value: unknown) => { + setValue(fieldName, value as PathValue, updateOptions) + }, + [setValue], + ) + + const onAddress = useCallback( + (address: FormattedAddress) => { + const values = addressValues(address, countries) + + updateValue(name.regionId, null) + updateValue(name.street, values.street) + updateValue(name.houseNumber, values.houseNumber) + updateValue(name.addition, values.addition) + updateValue(name.postcode, values.postcode) + updateValue(name.city, values.city) + updateValue(name.countryCode, values.countryCode) + if (countries) { + updateValue(name.regionId, values.regionId) + pendingRegion.current = null + } else { + pendingRegion.current = address + } + }, + [countries, name, updateValue], + ) + + const onPlaceChanged = useCallback(() => { + const addressComponents = autocomplete?.getPlace().address_components + if (addressComponents) onAddress(formatAddress({ addressComponents })) + }, [autocomplete, onAddress]) + + useEffect(() => { + const address = pendingRegion.current + if (!address || !countries) return + + pendingRegion.current = null + if (getValues(name.countryCode) !== address.country) return + + updateValue(name.regionId, addressValues(address, countries).regionId) + }, [countries, getValues, name.countryCode, name.regionId, updateValue]) + + const showAutocomplete = Boolean(googleMapsApiKey && isLoaded && !loadError) + + return ( + <> + {showAutocomplete ? ( + setAutocomplete(null)} + options={autocompleteOptions} + > + Street} + showValid + inputProps={{ + // Force disabling autocomplete on the input field, as it can cause issues with the Google Places Autocomplete + autoComplete: 'one-time-code', + name: addressSearchName, + }} + InputProps={{ + readOnly, + }} + /> + + ) : ( + fallback + )} + + Address search is unavailable. You can enter the address manually. + + + ) +} diff --git a/packages/address-autocomplete/hooks/useCountries.ts b/packages/address-autocomplete/hooks/useCountries.ts new file mode 100644 index 0000000000..779081d7c3 --- /dev/null +++ b/packages/address-autocomplete/hooks/useCountries.ts @@ -0,0 +1,9 @@ +import { useQuery } from '@graphcommerce/graphql' +import { CountryRegionsDocument } from '@graphcommerce/magento-store' + +export function useCountries() { + const countryQuery = useQuery(CountryRegionsDocument) + const countries = countryQuery.data?.countries ?? countryQuery.previousData?.countries + + return countries +} diff --git a/packages/address-autocomplete/index.ts b/packages/address-autocomplete/index.ts new file mode 100644 index 0000000000..dbff52ef70 --- /dev/null +++ b/packages/address-autocomplete/index.ts @@ -0,0 +1,5 @@ +export * from './components/AddressAutocomplete' +export * from './hooks/useCountries' +export * from './utils/addressValues' +export * from './utils/findRegionId' +export * from './utils/formatAddress' diff --git a/packages/address-autocomplete/package.json b/packages/address-autocomplete/package.json new file mode 100644 index 0000000000..0762fdfcb0 --- /dev/null +++ b/packages/address-autocomplete/package.json @@ -0,0 +1,38 @@ +{ + "name": "@graphcommerce/address-autocomplete", + "homepage": "https://www.graphcommerce.org/", + "repository": "github:graphcommerce-org/graphcommerce", + "version": "10.1.0-canary.39", + "sideEffects": false, + "prettier": "@graphcommerce/prettier-config-pwa", + "eslintConfig": { + "extends": "@graphcommerce/eslint-config-pwa", + "parserOptions": { + "project": "./tsconfig.json" + } + }, + "exports": { + ".": "./index.ts", + "./plugins/AddAddressAutocompleteAddressFields": "./plugins/AddAddressAutocompleteAddressFields.tsx" + }, + "dependencies": { + "@react-google-maps/api": "^2.20.8", + "@types/google.maps": "^3.59.0" + }, + "peerDependencies": { + "@graphcommerce/ecommerce-ui": "^10.1.0-canary.39", + "@graphcommerce/eslint-config-pwa": "^10.1.0-canary.39", + "@graphcommerce/graphql": "^10.1.0-canary.39", + "@graphcommerce/magento-customer": "^10.1.0-canary.39", + "@graphcommerce/magento-store": "^10.1.0-canary.39", + "@graphcommerce/next-config": "^10.1.0-canary.39", + "@graphcommerce/next-ui": "^10.1.0-canary.39", + "@graphcommerce/prettier-config-pwa": "^10.1.0-canary.39", + "@graphcommerce/typescript-config-pwa": "^10.1.0-canary.39", + "@lingui/core": "^5", + "@lingui/macro": "^5", + "@lingui/react": "^5", + "react": "^19.2.0", + "react-dom": "^19.2.0" + } +} diff --git a/packages/address-autocomplete/plugins/AddAddressAutocompleteAddressFields.tsx b/packages/address-autocomplete/plugins/AddAddressAutocompleteAddressFields.tsx new file mode 100644 index 0000000000..b6a15c8fef --- /dev/null +++ b/packages/address-autocomplete/plugins/AddAddressAutocompleteAddressFields.tsx @@ -0,0 +1,19 @@ +import type { FieldPath, FieldValues } from '@graphcommerce/ecommerce-ui' +import type { AddressFieldsOptions } from '@graphcommerce/magento-customer' +import type { PluginConfig, PluginProps } from '@graphcommerce/next-config' +import { AddressAutocomplete } from '../components/AddressAutocomplete' + +export const config: PluginConfig = { + type: 'component', + module: '@graphcommerce/magento-customer', + ifConfig: 'googleMapsApiKey', +} + +export function AddressStreet< + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath, +>(props: PluginProps>) { + const { Prev, ...rest } = props + + return } /> +} diff --git a/packages/address-autocomplete/tsconfig.json b/packages/address-autocomplete/tsconfig.json new file mode 100644 index 0000000000..83d9e34744 --- /dev/null +++ b/packages/address-autocomplete/tsconfig.json @@ -0,0 +1,9 @@ +{ + "compilerOptions": { + "moduleResolution": "Bundler", + "types": ["vitest/globals"] + }, + "exclude": ["node_modules"], + "include": ["**/*.ts", "**/*.tsx"], + "extends": "@graphcommerce/typescript-config-pwa/nextjs.json" +} diff --git a/packages/address-autocomplete/utils/addressValues.test.ts b/packages/address-autocomplete/utils/addressValues.test.ts new file mode 100644 index 0000000000..cbab7ab154 --- /dev/null +++ b/packages/address-autocomplete/utils/addressValues.test.ts @@ -0,0 +1,38 @@ +import { addressValues } from './addressValues' +import type { FormattedAddress } from './formatAddress' + +const emptyAddress: FormattedAddress = { + street: '', + houseNumber: '', + addition: '', + postcode: '', + city: '', + country: 'NL', + region: '', + regionCode: '', +} + +describe('addressValues', () => { + it('provides explicit empty values so a partial selection clears stale form data', () => { + expect(addressValues(emptyAddress)).toEqual({ + street: '', + houseNumber: '', + addition: '', + postcode: '', + city: '', + countryCode: 'NL', + regionId: null, + }) + }) + + it('resolves the region when country metadata is available', () => { + expect( + addressValues({ ...emptyAddress, country: 'US', region: 'California', regionCode: 'CA' }, [ + { + two_letter_abbreviation: 'US', + available_regions: [{ id: 12, code: 'CA', name: 'California' }], + }, + ]).regionId, + ).toBe(12) + }) +}) diff --git a/packages/address-autocomplete/utils/addressValues.ts b/packages/address-autocomplete/utils/addressValues.ts new file mode 100644 index 0000000000..75cf607d63 --- /dev/null +++ b/packages/address-autocomplete/utils/addressValues.ts @@ -0,0 +1,18 @@ +import type { AddressCountry } from './findRegionId' +import { findRegionId } from './findRegionId' +import type { FormattedAddress } from './formatAddress' + +export function addressValues( + address: FormattedAddress, + countries?: readonly (AddressCountry | null)[] | null, +) { + return { + street: address.street, + houseNumber: address.houseNumber, + addition: address.addition, + postcode: address.postcode, + city: address.city, + countryCode: address.country, + regionId: findRegionId(countries, address), + } +} diff --git a/packages/address-autocomplete/utils/findRegionId.test.ts b/packages/address-autocomplete/utils/findRegionId.test.ts new file mode 100644 index 0000000000..cf35e9a54e --- /dev/null +++ b/packages/address-autocomplete/utils/findRegionId.test.ts @@ -0,0 +1,43 @@ +import { findRegionId } from './findRegionId' + +const countries = [ + { + two_letter_abbreviation: 'CA', + available_regions: [ + { id: 1, code: 'QC', name: 'Québec' }, + { id: 2, code: 'ON', name: 'Ontario' }, + ], + }, +] + +describe('findRegionId', () => { + it('prefers the stable region code', () => { + expect( + findRegionId(countries, { + country: 'ca', + regionCode: 'QC', + region: 'Different localized name', + }), + ).toBe(1) + }) + + it('falls back to a normalized region name', () => { + expect( + findRegionId(countries, { + country: 'CA', + regionCode: '', + region: 'Quebec', + }), + ).toBe(1) + }) + + it('returns null rather than retaining an unrelated region', () => { + expect( + findRegionId(countries, { + country: 'US', + regionCode: 'CA', + region: 'California', + }), + ).toBeNull() + }) +}) diff --git a/packages/address-autocomplete/utils/findRegionId.ts b/packages/address-autocomplete/utils/findRegionId.ts new file mode 100644 index 0000000000..e7913ba476 --- /dev/null +++ b/packages/address-autocomplete/utils/findRegionId.ts @@ -0,0 +1,42 @@ +import type { FormattedAddress } from './formatAddress' + +export type AddressRegion = { + code?: string | null + id?: number | null + name?: string | null +} + +export type AddressCountry = { + available_regions?: readonly (AddressRegion | null)[] | null + two_letter_abbreviation?: string | null +} + +function normalize(value?: string | null) { + return value + ?.normalize('NFKD') + .replace(/\p{Diacritic}/gu, '') + .trim() + .toLocaleLowerCase() +} + +export function findRegionId( + countries: readonly (AddressCountry | null)[] | null | undefined, + address: Pick, +): number | null { + const countryCode = normalize(address.country) + const country = countries?.find( + (candidate) => normalize(candidate?.two_letter_abbreviation) === countryCode, + ) + + if (!country) return null + + const regionCode = normalize(address.regionCode) + const regionName = normalize(address.region) + const region = country.available_regions?.find((candidate) => { + if (!candidate) return false + if (regionCode && normalize(candidate.code) === regionCode) return true + return Boolean(regionName && normalize(candidate.name) === regionName) + }) + + return region?.id ?? null +} diff --git a/packages/address-autocomplete/utils/formatAddress.test.ts b/packages/address-autocomplete/utils/formatAddress.test.ts new file mode 100644 index 0000000000..ad0da916e1 --- /dev/null +++ b/packages/address-autocomplete/utils/formatAddress.test.ts @@ -0,0 +1,73 @@ +import type { AddressComponent } from './formatAddress' +import { formatAddress } from './formatAddress' + +function component(longText: string, shortText: string, types: string[]): AddressComponent { + return { longText, shortText, types } +} + +describe('formatAddress', () => { + it('maps a selected address to the GraphCommerce address fields', () => { + const result = formatAddress({ + addressComponents: [ + component('221B', '221B', ['premise', 'street_number']), + component('Baker Street', 'Baker St', ['political', 'route']), + component('Flat A', 'A', ['subpremise']), + component('London', 'London', ['postal_town']), + component('England', 'ENG', ['administrative_area_level_1']), + component('United Kingdom', 'GB', ['country']), + component('NW1 6XE', 'NW1 6XE', ['postal_code']), + ], + }) + + expect(result).toEqual({ + street: 'Baker Street', + houseNumber: '221B', + addition: 'A', + postcode: 'NW1 6XE', + city: 'London', + country: 'GB', + region: 'England', + regionCode: 'ENG', + }) + }) + + it('uses locality first and falls back to sublocality for cities', () => { + const locality = formatAddress({ + addressComponents: [ + component('Montréal', 'Montréal', ['locality']), + component('Outremont', 'Outremont', ['sublocality_level_1']), + ], + }) + const sublocality = formatAddress({ + addressComponents: [component('Brooklyn', 'Brooklyn', ['sublocality_level_1'])], + }) + + expect(locality.city).toBe('Montréal') + expect(sublocality.city).toBe('Brooklyn') + }) + + it('recognizes postal-code prefixes and suffixes', () => { + expect( + formatAddress({ + addressComponents: [component('SW1A', 'SW1A', ['postal_code_prefix'])], + }).postcode, + ).toBe('SW1A') + + expect( + formatAddress({ + addressComponents: [ + component('12345', '12345', ['postal_code']), + component('6789', '6789', ['postal_code_suffix']), + ], + }).postcode, + ).toBe('12345-6789') + }) + + it('supports legacy geocoder components for backwards compatibility', () => { + const result = formatAddress({ + addressComponents: [{ long_name: 'Main Street', short_name: 'Main St', types: ['route'] }], + }) + + expect(result.street).toBe('Main Street') + }) +}) diff --git a/packages/address-autocomplete/utils/formatAddress.ts b/packages/address-autocomplete/utils/formatAddress.ts new file mode 100644 index 0000000000..1ee24a8e30 --- /dev/null +++ b/packages/address-autocomplete/utils/formatAddress.ts @@ -0,0 +1,68 @@ +type PlacesAddressComponent = Pick< + google.maps.places.AddressComponent, + 'longText' | 'shortText' | 'types' +> + +type GeocoderAddressComponent = Pick< + google.maps.GeocoderAddressComponent, + 'long_name' | 'short_name' | 'types' +> + +export type AddressComponent = PlacesAddressComponent | GeocoderAddressComponent + +export type FormatAddressProps = { + addressComponents: readonly AddressComponent[] +} + +/** @deprecated Use `FormatAddressProps` instead. */ +export type formatAddressProps = FormatAddressProps + +export type FormattedAddress = { + street: string + houseNumber: string + addition: string + postcode: string + city: string + country: string + region: string + regionCode: string +} + +function getLongText(component: AddressComponent) { + return 'longText' in component ? (component.longText ?? '') : component.long_name +} + +function getShortText(component: AddressComponent) { + return 'shortText' in component ? (component.shortText ?? '') : component.short_name +} + +function findComponent(addressComponents: readonly AddressComponent[], type: string) { + return addressComponents.find((component) => component.types.includes(type)) +} + +export function formatAddress(props: FormatAddressProps): FormattedAddress { + const { addressComponents } = props + + const getLong = (type: string) => { + const component = findComponent(addressComponents, type) + return component ? getLongText(component) : '' + } + const getShort = (type: string) => { + const component = findComponent(addressComponents, type) + return component ? getShortText(component) : '' + } + + const postcode = getShort('postal_code') || getShort('postal_code_prefix') + const postcodeSuffix = getShort('postal_code_suffix') + + return { + street: getLong('route'), + houseNumber: getShort('street_number'), + addition: getShort('subpremise'), + postcode: postcodeSuffix ? `${postcode}-${postcodeSuffix}` : postcode, + city: getLong('locality') || getLong('postal_town') || getLong('sublocality_level_1'), + country: getShort('country'), + region: getLong('administrative_area_level_1'), + regionCode: getShort('administrative_area_level_1'), + } +} From eef2a234020aaf6ac3f9385b344cdb8f77f0b3f3 Mon Sep 17 00:00:00 2001 From: Hassan Goodarzi Date: Wed, 5 Aug 2026 12:02:06 +0330 Subject: [PATCH 2/6] fix(address-autocomplete): handle Dutch house number additions --- .../utils/formatAddress.test.ts | 13 +++++++++++++ .../address-autocomplete/utils/formatAddress.ts | 14 +++++++++++--- 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/packages/address-autocomplete/utils/formatAddress.test.ts b/packages/address-autocomplete/utils/formatAddress.test.ts index ad0da916e1..275f372448 100644 --- a/packages/address-autocomplete/utils/formatAddress.test.ts +++ b/packages/address-autocomplete/utils/formatAddress.test.ts @@ -46,6 +46,19 @@ describe('formatAddress', () => { expect(sublocality.city).toBe('Brooklyn') }) + it('maps a Dutch street-number suffix to the addition field', () => { + const result = formatAddress({ + addressComponents: [ + component('94a', '94a', ['street_number']), + component('Noordeinde', 'Noordeinde', ['route']), + component('Nederland', 'NL', ['country']), + ], + }) + + expect(result.houseNumber).toBe('94') + expect(result.addition).toBe('a') + }) + it('recognizes postal-code prefixes and suffixes', () => { expect( formatAddress({ diff --git a/packages/address-autocomplete/utils/formatAddress.ts b/packages/address-autocomplete/utils/formatAddress.ts index 1ee24a8e30..0bdc9567ac 100644 --- a/packages/address-autocomplete/utils/formatAddress.ts +++ b/packages/address-autocomplete/utils/formatAddress.ts @@ -54,14 +54,22 @@ export function formatAddress(props: FormatAddressProps): FormattedAddress { const postcode = getShort('postal_code') || getShort('postal_code_prefix') const postcodeSuffix = getShort('postal_code_suffix') + const country = getShort('country') + let houseNumber = getShort('street_number') + let addition = getShort('subpremise') + + if (country === 'NL' && !addition) { + const houseNumberParts = houseNumber.match(/^(\d+)([a-zA-Z]+)$/) + if (houseNumberParts) [, houseNumber, addition] = houseNumberParts + } return { street: getLong('route'), - houseNumber: getShort('street_number'), - addition: getShort('subpremise'), + houseNumber, + addition, postcode: postcodeSuffix ? `${postcode}-${postcodeSuffix}` : postcode, city: getLong('locality') || getLong('postal_town') || getLong('sublocality_level_1'), - country: getShort('country'), + country, region: getLong('administrative_area_level_1'), regionCode: getShort('administrative_area_level_1'), } From 4d0423fa4c1369faeb7f5f5b221d471f3843788e Mon Sep 17 00:00:00 2001 From: Hassan Goodarzi Date: Wed, 5 Aug 2026 15:07:36 +0330 Subject: [PATCH 3/6] feat(address-autocomplete): migrate to Places API New with custom autocomplete UI --- packages/address-autocomplete/README.md | 5 + .../components/AddressAutocomplete.test.tsx | 233 +++++++++++++----- .../components/AddressAutocomplete.tsx | 106 ++++---- .../components/AddressAutocompletePopper.tsx | 73 ++++++ .../hooks/usePlacesAutocomplete.ts | 192 +++++++++++++++ packages/address-autocomplete/package.json | 3 +- 6 files changed, 502 insertions(+), 110 deletions(-) create mode 100644 packages/address-autocomplete/components/AddressAutocompletePopper.tsx create mode 100644 packages/address-autocomplete/hooks/usePlacesAutocomplete.ts diff --git a/packages/address-autocomplete/README.md b/packages/address-autocomplete/README.md index 75e7285efb..3fc0443e08 100644 --- a/packages/address-autocomplete/README.md +++ b/packages/address-autocomplete/README.md @@ -54,8 +54,13 @@ The plugin replaces the standard `AddressStreet` field only when `googleMapsApiKey` is configured. It: - loads the Google Maps Places library; +- requests predictions through the Places API (New) `AutocompleteSuggestion` + interface; - limits suggestions to addresses; - uses the browser's preferred language for suggestions; +- renders an accessible suggestions list with the required Google Maps + attribution; +- groups prediction and place-detail requests into autocomplete sessions; - maps Google address components to GraphCommerce address fields; - resolves the Google region to the corresponding Magento region ID; - preserves the standard field styling and validation behavior; diff --git a/packages/address-autocomplete/components/AddressAutocomplete.test.tsx b/packages/address-autocomplete/components/AddressAutocomplete.test.tsx index d7f3ec21c8..0af7ff88d4 100644 --- a/packages/address-autocomplete/components/AddressAutocomplete.test.tsx +++ b/packages/address-autocomplete/components/AddressAutocomplete.test.tsx @@ -5,27 +5,44 @@ import type { Root } from 'react-dom/client' import { createRoot } from 'react-dom/client' import { AddressAutocomplete } from './AddressAutocomplete' -const { loaderOptions, loaderState } = vi.hoisted(() => ({ +const { loaderOptions } = vi.hoisted(() => ({ loaderOptions: [] as unknown[], - loaderState: { - isLoaded: false, - loadError: new Error('Maps failed') as Error | undefined, - }, })) -const { autocompleteOptions } = vi.hoisted(() => ({ - autocompleteOptions: [] as unknown[], +const { fetchAutocompleteSuggestions, importLibrary, setValue } = vi.hoisted(() => ({ + fetchAutocompleteSuggestions: vi.fn(), + importLibrary: vi.fn(), + setValue: vi.fn(), })) vi.mock('@graphcommerce/ecommerce-ui', () => ({ TextFieldElement: ({ inputProps, + inputRef, name, + onChange, + onFocus, + onKeyDown, variant, }: { inputProps?: React.InputHTMLAttributes + inputRef?: React.Ref name: string + onChange?: React.ChangeEventHandler + onFocus?: React.FocusEventHandler + onKeyDown?: React.KeyboardEventHandler variant: string - }) => , + }) => ( + + ), })) vi.mock('@graphcommerce/magento-customer', () => ({ @@ -43,7 +60,7 @@ vi.mock('@graphcommerce/magento-customer', () => ({ }, readOnly: false, required: { street: true }, - setValue: vi.fn(), + setValue, }), })) @@ -64,14 +81,65 @@ vi.mock('@graphcommerce/next-ui', () => ({ open ?
{children}
: null, })) -vi.mock('@react-google-maps/api', () => ({ - Autocomplete: ({ children, options }: { children: React.ReactNode; options: unknown }) => { - autocompleteOptions.push(options) - return
{children}
- }, - useLoadScript: (options: unknown) => { - loaderOptions.push(options) - return loaderState +vi.mock('@mui/material', () => ({ + Box: ({ children }: { children: React.ReactNode }) =>
{children}
, + CircularProgress: () => , + ClickAwayListener: ({ children }: { children: React.ReactNode }) => children, + List: ({ children, id, role }: { children: React.ReactNode; id: string; role: string }) => ( +
    + {children} +
+ ), + ListItemButton: ({ + children, + id, + onClick, + onMouseDown, + onMouseEnter, + role, + }: { + children: React.ReactNode + id: string + onClick: React.MouseEventHandler + onMouseDown: React.MouseEventHandler + onMouseEnter: React.MouseEventHandler + role: string + }) => ( + + ), + ListItemText: ({ + primary, + secondary, + }: { + primary: React.ReactNode + secondary?: React.ReactNode + }) => ( + + {primary} {secondary} + + ), + Paper: ({ children }: { children: React.ReactNode }) =>
{children}
, + Popper: ({ children, open }: { children: React.ReactNode; open: boolean }) => + open ?
{children}
: null, + Typography: ({ children }: { children: React.ReactNode }) => {children}, +})) + +vi.mock('@googlemaps/js-api-loader', () => ({ + Loader: class { + constructor(options: unknown) { + loaderOptions.push(options) + } + + importLibrary = importLibrary }, })) @@ -84,16 +152,16 @@ afterEach(() => { testContainer?.remove() testRoot = undefined testContainer = undefined - loaderState.isLoaded = false - loaderState.loadError = new Error('Maps failed') loaderOptions.length = 0 - autocompleteOptions.length = 0 + vi.useRealTimers() vi.clearAllMocks() }) let testRoot: Root | undefined let testContainer: HTMLDivElement | undefined +class AutocompleteSessionToken {} + beforeAll(() => { Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }) }) @@ -102,56 +170,109 @@ afterAll(() => { Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT') }) +function renderAutocomplete() { + testContainer = document.createElement('div') + document.body.appendChild(testContainer) + testRoot = createRoot(testContainer) + testRoot.render( + Manual street input} + />, + ) +} + describe('AddressAutocomplete', () => { - it('keeps manual street entry available when Google Maps fails', () => { - testContainer = document.createElement('div') - document.body.appendChild(testContainer) - testRoot = createRoot(testContainer) - - act(() => { - testRoot?.render( - Manual street input} - />, - ) - }) + it('keeps manual street entry available when Google Maps fails', async () => { + importLibrary.mockRejectedValue(new Error('Maps failed')) - expect(testContainer.textContent).toContain('Manual street input') - expect(testContainer.textContent).toContain( + await act(async () => renderAutocomplete()) + + expect(testContainer?.textContent).toContain('Manual street input') + expect(testContainer?.textContent).toContain( 'Address search is unavailable. You can enter the address manually.', ) }) - it('uses the standard styled street field after Google Maps loads', () => { - loaderState.isLoaded = true - loaderState.loadError = undefined - testContainer = document.createElement('div') - document.body.appendChild(testContainer) - testRoot = createRoot(testContainer) - - act(() => { - testRoot?.render( - Manual street input} - />, - ) + it('uses the standard styled street field after Google Maps loads', async () => { + importLibrary.mockResolvedValue({ + AutocompleteSessionToken, + AutocompleteSuggestion: { fetchAutocompleteSuggestions }, }) - const streetInput = testContainer.querySelector('input[aria-label="Street"]') + await act(async () => renderAutocomplete()) + + const streetInput = testContainer?.querySelector('input[aria-label="Street"]') expect(streetInput).not.toBeNull() expect(streetInput?.dataset.variant).toBe('outlined') expect(streetInput?.getAttribute('autocomplete')).toBe('one-time-code') + expect(streetInput?.getAttribute('role')).toBe('combobox') expect(streetInput?.name).toMatch(/^places-search-/) - expect(testContainer.querySelector('[data-google-autocomplete]')).not.toBeNull() expect(loaderOptions.at(-1)).toEqual({ - googleMapsApiKey: 'test-key', - libraries: ['places'], + apiKey: 'test-key', + version: 'weekly', }) - expect(autocompleteOptions.at(-1)).toEqual({ - fields: ['address_components'], - types: ['address'], + expect(importLibrary).toHaveBeenCalledWith('places') + }) + + it('loads new Places suggestions and fills the selected address', async () => { + vi.useFakeTimers() + + const fetchFields = vi.fn().mockResolvedValue(undefined) + const place = { + addressComponents: [ + { longText: 'Baker Street', shortText: 'Baker St', types: ['route'] }, + { longText: '221B', shortText: '221B', types: ['street_number'] }, + { longText: 'London', shortText: 'London', types: ['postal_town'] }, + { longText: 'United Kingdom', shortText: 'GB', types: ['country'] }, + { longText: 'NW1 6XE', shortText: 'NW1 6XE', types: ['postal_code'] }, + ], + fetchFields, + } + const prediction = { + mainText: { text: '221B Baker Street' }, + placeId: 'baker-street', + secondaryText: { text: 'London, UK' }, + text: { text: '221B Baker Street, London, UK' }, + toPlace: () => place, + } + fetchAutocompleteSuggestions.mockResolvedValue({ + suggestions: [{ placePrediction: prediction }], + }) + importLibrary.mockResolvedValue({ + AutocompleteSessionToken, + AutocompleteSuggestion: { fetchAutocompleteSuggestions }, + }) + + await act(async () => renderAutocomplete()) + + const streetInput = testContainer?.querySelector('input[aria-label="Street"]') + expect(streetInput).not.toBeNull() + + await act(async () => { + if (!streetInput) return + const valueSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set + valueSetter?.call(streetInput, 'Baker') + streetInput.dispatchEvent(new Event('input', { bubbles: true })) + await vi.advanceTimersByTimeAsync(250) + }) + + expect(fetchAutocompleteSuggestions).toHaveBeenCalledWith({ + includedPrimaryTypes: ['street_address', 'premise', 'subpremise', 'route'], + input: 'Baker', + sessionToken: expect.any(AutocompleteSessionToken), }) + expect(testContainer?.textContent).toContain('221B Baker Street') + expect(testContainer?.textContent).toContain('Google Maps') + + const option = testContainer?.querySelector('button[role="option"]') + await act(async () => option?.click()) + + expect(fetchFields).toHaveBeenCalledWith({ fields: ['addressComponents'] }) + expect(setValue).toHaveBeenCalledWith('street', 'Baker Street', expect.any(Object)) + expect(setValue).toHaveBeenCalledWith('houseNumber', '221B', expect.any(Object)) + expect(setValue).toHaveBeenCalledWith('postcode', 'NW1 6XE', expect.any(Object)) + expect(setValue).toHaveBeenCalledWith('city', 'London', expect.any(Object)) + expect(setValue).toHaveBeenCalledWith('countryCode', 'GB', expect.any(Object)) }) }) diff --git a/packages/address-autocomplete/components/AddressAutocomplete.tsx b/packages/address-autocomplete/components/AddressAutocomplete.tsx index 95aa2f7e05..2cd2708c87 100644 --- a/packages/address-autocomplete/components/AddressAutocomplete.tsx +++ b/packages/address-autocomplete/components/AddressAutocomplete.tsx @@ -2,27 +2,17 @@ import { TextFieldElement } from '@graphcommerce/ecommerce-ui' import type { FieldPath, FieldValues, PathValue } from '@graphcommerce/ecommerce-ui' import type { AddressFieldsOptions } from '@graphcommerce/magento-customer' import { useAddressFieldsForm } from '@graphcommerce/magento-customer' -import { googleMapsApiKey } from '@graphcommerce/next-config/config' import { ErrorSnackbar } from '@graphcommerce/next-ui' import { Trans } from '@lingui/react/macro' -import { - Autocomplete, - useLoadScript, - type AutocompleteProps, - type Libraries, -} from '@react-google-maps/api' +import { Box, CircularProgress, ClickAwayListener } from '@mui/material' import type { ReactNode } from 'react' -import { useCallback, useEffect, useId, useRef, useState } from 'react' +import { useCallback, useEffect, useRef } from 'react' import { useCountries } from '../hooks/useCountries' +import { usePlacesAutocomplete } from '../hooks/usePlacesAutocomplete' import { addressValues } from '../utils/addressValues' import type { FormattedAddress } from '../utils/formatAddress' -import { formatAddress } from '../utils/formatAddress' +import { AddressAutocompletePopper } from './AddressAutocompletePopper' -const libraries: Libraries = ['places'] -const autocompleteOptions: AutocompleteProps['options'] = { - fields: ['address_components'], - types: ['address'], -} const updateOptions = { shouldDirty: true, shouldTouch: true, @@ -43,14 +33,8 @@ export function AddressAutocomplete< const { fallback } = options const form = useAddressFieldsForm(options) const { control, getValues, name, readOnly, required, setValue } = form - const [autocomplete, setAutocomplete] = useState(null) const pendingRegion = useRef(null) - const addressSearchName = `places-search-${useId()}` const countries = useCountries() - const { isLoaded, loadError } = useLoadScript({ - googleMapsApiKey: googleMapsApiKey ?? '', - libraries, - }) const updateValue = useCallback( (fieldName: TName, value: unknown) => { @@ -80,10 +64,7 @@ export function AddressAutocomplete< [countries, name, updateValue], ) - const onPlaceChanged = useCallback(() => { - const addressComponents = autocomplete?.getPlace().address_components - if (addressComponents) onAddress(formatAddress({ addressComponents })) - }, [autocomplete, onAddress]) + const places = usePlacesAutocomplete({ onAddress }) useEffect(() => { const address = pendingRegion.current @@ -95,40 +76,59 @@ export function AddressAutocomplete< updateValue(name.regionId, addressValues(address, countries).regionId) }, [countries, getValues, name.countryCode, name.regionId, updateValue]) - const showAutocomplete = Boolean(googleMapsApiKey && isLoaded && !loadError) - return ( <> - {showAutocomplete ? ( - setAutocomplete(null)} - options={autocompleteOptions} - > - Street} - showValid - inputProps={{ - // Force disabling autocomplete on the input field, as it can cause issues with the Google Places Autocomplete - autoComplete: 'one-time-code', - name: addressSearchName, - }} - InputProps={{ - readOnly, - }} - /> - + {places.autocompleteAvailable ? ( + + + Street} + showValid={!places.loading} + onChange={places.onChange} + onFocus={places.onFocus} + onKeyDown={places.onKeyDown} + inputRef={places.setAnchorElement} + inputProps={{ + 'aria-activedescendant': + places.activeIndex >= 0 + ? `${places.listboxId}-option-${places.activeIndex}` + : undefined, + 'aria-autocomplete': 'list', + 'aria-busy': places.loading, + 'aria-controls': places.listboxOpen ? places.listboxId : undefined, + 'aria-expanded': places.listboxOpen, + 'aria-haspopup': 'listbox', + role: 'combobox', + // Force disabling browser autocomplete so it does not compete with Google suggestions. + autoComplete: 'one-time-code', + name: places.addressSearchName, + }} + InputProps={{ + readOnly, + endAdornment: places.loading ? : undefined, + }} + /> + void places.selectPrediction(prediction)} + /> + + ) : ( fallback )} - + Address search is unavailable. You can enter the address manually. diff --git a/packages/address-autocomplete/components/AddressAutocompletePopper.tsx b/packages/address-autocomplete/components/AddressAutocompletePopper.tsx new file mode 100644 index 0000000000..495c957777 --- /dev/null +++ b/packages/address-autocomplete/components/AddressAutocompletePopper.tsx @@ -0,0 +1,73 @@ +/// +import { Box, List, ListItemButton, ListItemText, Paper, Popper, Typography } from '@mui/material' + +export type AddressAutocompletePopperProps = { + activeIndex: number + anchorElement: HTMLInputElement | null + listboxId: string + open: boolean + predictions: google.maps.places.PlacePrediction[] + onActiveIndexChange: (index: number) => void + onSelect: (prediction: google.maps.places.PlacePrediction) => void +} + +export function AddressAutocompletePopper({ + activeIndex, + anchorElement, + listboxId, + open, + predictions, + onActiveIndexChange, + onSelect, +}: AddressAutocompletePopperProps) { + return ( + ({ + width: anchorElement?.offsetWidth, + zIndex: theme.zIndex.modal + 1, + })} + > + + + {predictions.map((prediction, index) => ( + event.preventDefault()} + onMouseEnter={() => onActiveIndexChange(index)} + onClick={() => onSelect(prediction)} + > + + + ))} + + ({ + color: theme.palette.mode === 'dark' ? '#fff' : '#5e5e5e', + fontFamily: 'Roboto, sans-serif', + fontSize: '0.75rem', + fontStyle: 'normal', + fontWeight: 400, + letterSpacing: 'normal', + whiteSpace: 'nowrap', + })} + > + Google Maps + + + + + + ) +} diff --git a/packages/address-autocomplete/hooks/usePlacesAutocomplete.ts b/packages/address-autocomplete/hooks/usePlacesAutocomplete.ts new file mode 100644 index 0000000000..98aa173b89 --- /dev/null +++ b/packages/address-autocomplete/hooks/usePlacesAutocomplete.ts @@ -0,0 +1,192 @@ +import { googleMapsApiKey } from '@graphcommerce/next-config/config' +import { Loader } from '@googlemaps/js-api-loader' +import type { ChangeEvent, KeyboardEvent } from 'react' +import { useCallback, useEffect, useId, useRef, useState } from 'react' +import type { FormattedAddress } from '../utils/formatAddress' +import { formatAddress } from '../utils/formatAddress' + +const addressPrimaryTypes = ['street_address', 'premise', 'subpremise', 'route'] +const requestDelay = 250 + +type PlacesAutocompleteLibrary = Pick< + google.maps.PlacesLibrary, + 'AutocompleteSessionToken' | 'AutocompleteSuggestion' +> + +export type UsePlacesAutocompleteOptions = { + onAddress: (address: FormattedAddress) => void +} + +export function usePlacesAutocomplete({ onAddress }: UsePlacesAutocompleteOptions) { + const [placesLibrary, setPlacesLibrary] = useState() + const [placesError, setPlacesError] = useState() + const [isLoaded, setIsLoaded] = useState(false) + const [predictions, setPredictions] = useState([]) + const [searchValue, setSearchValue] = useState('') + const [loading, setLoading] = useState(false) + const [open, setOpen] = useState(false) + const [activeIndex, setActiveIndex] = useState(-1) + const [anchorElement, setAnchorElement] = useState(null) + const sessionToken = useRef(undefined) + const requestNumber = useRef(0) + const addressSearchName = `places-search-${useId()}` + const listboxId = `places-listbox-${useId()}` + + useEffect(() => { + if (!googleMapsApiKey) return undefined + + let active = true + const loader = new Loader({ apiKey: googleMapsApiKey, version: 'weekly' }) + void loader + .importLibrary('places') + .then((library) => { + if (!active) return + setPlacesLibrary(library) + setIsLoaded(true) + setPlacesError(undefined) + }) + .catch((error: unknown) => { + if (!active) return + setIsLoaded(false) + setPlacesError(error instanceof Error ? error : new Error('Unable to load Google Places')) + }) + + return () => { + active = false + } + }, []) + + useEffect(() => { + const input = searchValue.trim() + const currentRequest = ++requestNumber.current + + if (!placesLibrary || input.length < 3) { + setLoading(false) + setPredictions([]) + setActiveIndex(-1) + if (!input) sessionToken.current = undefined + return undefined + } + + setLoading(true) + const timeout = window.setTimeout(() => { + sessionToken.current ??= new placesLibrary.AutocompleteSessionToken() + + void placesLibrary.AutocompleteSuggestion.fetchAutocompleteSuggestions({ + includedPrimaryTypes: addressPrimaryTypes, + input, + sessionToken: sessionToken.current, + }) + .then(({ suggestions }) => { + if (currentRequest !== requestNumber.current) return + + const nextPredictions = suggestions.flatMap((suggestion) => + suggestion.placePrediction ? [suggestion.placePrediction] : [], + ) + setPredictions(nextPredictions) + setActiveIndex(-1) + setOpen(nextPredictions.length > 0) + setPlacesError(undefined) + }) + .catch((error: unknown) => { + if (currentRequest !== requestNumber.current) return + setPredictions([]) + setOpen(false) + setPlacesError( + error instanceof Error ? error : new Error('Unable to search for addresses'), + ) + }) + .finally(() => { + if (currentRequest === requestNumber.current) setLoading(false) + }) + }, requestDelay) + + return () => window.clearTimeout(timeout) + }, [placesLibrary, searchValue]) + + const selectPrediction = useCallback( + async (prediction: google.maps.places.PlacePrediction) => { + requestNumber.current += 1 + setLoading(false) + setOpen(false) + setPredictions([]) + setActiveIndex(-1) + + try { + const place = prediction.toPlace() + await place.fetchFields({ fields: ['addressComponents'] }) + if (place.addressComponents) { + onAddress(formatAddress({ addressComponents: place.addressComponents })) + } + setPlacesError(undefined) + } catch (error) { + setPlacesError( + error instanceof Error ? error : new Error('Unable to retrieve the selected address'), + ) + } finally { + sessionToken.current = undefined + } + }, + [onAddress], + ) + + const closeSuggestions = useCallback(() => { + setOpen(false) + setActiveIndex(-1) + sessionToken.current = undefined + }, []) + + const onChange = useCallback((event: ChangeEvent) => { + setSearchValue(event.target.value) + }, []) + + const onFocus = useCallback(() => { + setOpen(predictions.length > 0) + }, [predictions.length]) + + const onKeyDown = useCallback( + (event: KeyboardEvent) => { + if (event.key === 'Escape' || event.key === 'Tab') { + closeSuggestions() + return + } + if (!predictions.length) return + + if (event.key === 'ArrowDown') { + event.preventDefault() + setOpen(true) + setActiveIndex((current) => (current + 1) % predictions.length) + } else if (event.key === 'ArrowUp') { + event.preventDefault() + setOpen(true) + setActiveIndex((current) => (current <= 0 ? predictions.length - 1 : current - 1)) + } else if (event.key === 'Enter' && open && activeIndex >= 0) { + event.preventDefault() + void selectPrediction(predictions[activeIndex]) + } + }, + [activeIndex, closeSuggestions, open, predictions, selectPrediction], + ) + + const autocompleteAvailable = Boolean(googleMapsApiKey && isLoaded) + const listboxOpen = autocompleteAvailable && open && predictions.length > 0 + + return { + activeIndex, + addressSearchName, + anchorElement, + autocompleteAvailable, + closeSuggestions, + error: placesError, + listboxId, + listboxOpen, + loading, + onChange, + onFocus, + onKeyDown, + predictions, + selectPrediction, + setActiveIndex, + setAnchorElement, + } +} diff --git a/packages/address-autocomplete/package.json b/packages/address-autocomplete/package.json index 0762fdfcb0..7a0b440d05 100644 --- a/packages/address-autocomplete/package.json +++ b/packages/address-autocomplete/package.json @@ -16,7 +16,7 @@ "./plugins/AddAddressAutocompleteAddressFields": "./plugins/AddAddressAutocompleteAddressFields.tsx" }, "dependencies": { - "@react-google-maps/api": "^2.20.8", + "@googlemaps/js-api-loader": "^1.16.8", "@types/google.maps": "^3.59.0" }, "peerDependencies": { @@ -32,6 +32,7 @@ "@lingui/core": "^5", "@lingui/macro": "^5", "@lingui/react": "^5", + "@mui/material": "^7.0.0", "react": "^19.2.0", "react-dom": "^19.2.0" } From c5eec7359db34773b05c997472dff45aad022c00 Mon Sep 17 00:00:00 2001 From: Hassan Goodarzi Date: Wed, 5 Aug 2026 15:16:52 +0330 Subject: [PATCH 4/6] fix(address-autocomplete): split house number letter suffixes --- .../utils/formatAddress.test.ts | 31 +++++++++++++++---- .../utils/formatAddress.ts | 4 +-- 2 files changed, 27 insertions(+), 8 deletions(-) diff --git a/packages/address-autocomplete/utils/formatAddress.test.ts b/packages/address-autocomplete/utils/formatAddress.test.ts index 275f372448..51fc2f45ff 100644 --- a/packages/address-autocomplete/utils/formatAddress.test.ts +++ b/packages/address-autocomplete/utils/formatAddress.test.ts @@ -46,17 +46,36 @@ describe('formatAddress', () => { expect(sublocality.city).toBe('Brooklyn') }) - it('maps a Dutch street-number suffix to the addition field', () => { + it.each([ + ['94a', 'NL', '94', 'a'], + ['221B', 'GB', '221', 'B'], + ['12/1β', 'GR', '12/1', 'β'], + ])( + 'maps the street-number suffix in %s to the addition field', + (streetNumber, country, expectedHouseNumber, expectedAddition) => { + const result = formatAddress({ + addressComponents: [ + component(streetNumber, streetNumber, ['street_number']), + component('Example Street', 'Example St', ['route']), + component(country, country, ['country']), + ], + }) + + expect(result.houseNumber).toBe(expectedHouseNumber) + expect(result.addition).toBe(expectedAddition) + }, + ) + + it('preserves an explicit subpremise instead of splitting the street number', () => { const result = formatAddress({ addressComponents: [ - component('94a', '94a', ['street_number']), - component('Noordeinde', 'Noordeinde', ['route']), - component('Nederland', 'NL', ['country']), + component('221B', '221B', ['street_number']), + component('Flat A', 'A', ['subpremise']), ], }) - expect(result.houseNumber).toBe('94') - expect(result.addition).toBe('a') + expect(result.houseNumber).toBe('221B') + expect(result.addition).toBe('A') }) it('recognizes postal-code prefixes and suffixes', () => { diff --git a/packages/address-autocomplete/utils/formatAddress.ts b/packages/address-autocomplete/utils/formatAddress.ts index 0bdc9567ac..4772687eab 100644 --- a/packages/address-autocomplete/utils/formatAddress.ts +++ b/packages/address-autocomplete/utils/formatAddress.ts @@ -58,8 +58,8 @@ export function formatAddress(props: FormatAddressProps): FormattedAddress { let houseNumber = getShort('street_number') let addition = getShort('subpremise') - if (country === 'NL' && !addition) { - const houseNumberParts = houseNumber.match(/^(\d+)([a-zA-Z]+)$/) + if (!addition) { + const houseNumberParts = houseNumber.match(/^(\d+(?:[-/]\d+)*)(\p{L}+)$/u) if (houseNumberParts) [, houseNumber, addition] = houseNumberParts } From 57f42c8cc9268ec405d056b36b41b4149caa0161 Mon Sep 17 00:00:00 2001 From: Hassan Goodarzi Date: Wed, 5 Aug 2026 15:32:03 +0330 Subject: [PATCH 5/6] test(address-autocomplete): align house number suffix expectations --- .../components/AddressAutocomplete.test.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/address-autocomplete/components/AddressAutocomplete.test.tsx b/packages/address-autocomplete/components/AddressAutocomplete.test.tsx index 0af7ff88d4..c74b316549 100644 --- a/packages/address-autocomplete/components/AddressAutocomplete.test.tsx +++ b/packages/address-autocomplete/components/AddressAutocomplete.test.tsx @@ -270,7 +270,8 @@ describe('AddressAutocomplete', () => { expect(fetchFields).toHaveBeenCalledWith({ fields: ['addressComponents'] }) expect(setValue).toHaveBeenCalledWith('street', 'Baker Street', expect.any(Object)) - expect(setValue).toHaveBeenCalledWith('houseNumber', '221B', expect.any(Object)) + expect(setValue).toHaveBeenCalledWith('houseNumber', '221', expect.any(Object)) + expect(setValue).toHaveBeenCalledWith('addition', 'B', expect.any(Object)) expect(setValue).toHaveBeenCalledWith('postcode', 'NW1 6XE', expect.any(Object)) expect(setValue).toHaveBeenCalledWith('city', 'London', expect.any(Object)) expect(setValue).toHaveBeenCalledWith('countryCode', 'GB', expect.any(Object)) From ef3746f044a63707041a53c558071a2358da0b40 Mon Sep 17 00:00:00 2001 From: Hassan Goodarzi Date: Wed, 5 Aug 2026 15:43:03 +0330 Subject: [PATCH 6/6] docs(address-autocomplete): update README --- packages/address-autocomplete/README.md | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/packages/address-autocomplete/README.md b/packages/address-autocomplete/README.md index 3fc0443e08..07f41e12c3 100644 --- a/packages/address-autocomplete/README.md +++ b/packages/address-autocomplete/README.md @@ -26,6 +26,8 @@ yarn codegen The package uses the GraphCommerce plugin system, so no component overrides are required. +Google Maps is loaded directly with the official `@googlemaps/js-api-loader`. + ## Google Maps setup Create a Google Maps API key with the Places API (New) enabled. Restrict the key @@ -53,15 +55,23 @@ See [Config.graphqls](./Config.graphqls) for the available configuration fields. The plugin replaces the standard `AddressStreet` field only when `googleMapsApiKey` is configured. It: -- loads the Google Maps Places library; +- loads the Google Maps Places library directly with + `@googlemaps/js-api-loader`; - requests predictions through the Places API (New) `AutocompleteSuggestion` interface; +- starts searching after three characters and debounces requests by 250 ms; - limits suggestions to addresses; - uses the browser's preferred language for suggestions; -- renders an accessible suggestions list with the required Google Maps - attribution; +- keeps the GraphCommerce `TextFieldElement` and renders predictions in a custom + Material UI popper; +- supports mouse and keyboard selection with the required combobox attributes; +- displays the required Google Maps attribution with the predictions; - groups prediction and place-detail requests into autocomplete sessions; +- retrieves the selected address through `Place.fetchFields()`; - maps Google address components to GraphCommerce address fields; +- splits a street-number suffix into the addition field when Google does not + provide a separate subpremise, for example `221B` becomes house number `221` + and addition `B`; - resolves the Google region to the corresponding Magento region ID; - preserves the standard field styling and validation behavior; - suppresses browser address autofill from competing with Google suggestions; @@ -101,6 +111,12 @@ Check that: The manual street field is displayed when the key is missing or the Google Maps script cannot be loaded. +### Google Maps attribution + +The suggestions popper displays Google Maps content without an accompanying +Google Map. The `Google Maps` attribution shown with the predictions is +therefore required and should not be removed, hidden, or translated. + ### A selected region is not filled The package matches regions by country, region code, and normalized region name.