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..07f41e12c3 --- /dev/null +++ b/packages/address-autocomplete/README.md @@ -0,0 +1,124 @@ +# @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 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 +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 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; +- 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; + 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. + +### 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. +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..c74b316549 --- /dev/null +++ b/packages/address-autocomplete/components/AddressAutocomplete.test.tsx @@ -0,0 +1,279 @@ +// @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 } = vi.hoisted(() => ({ + loaderOptions: [] 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', () => ({ + 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.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('@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 + }, +})) + +vi.mock('../hooks/useCountries', () => ({ + useCountries: () => undefined, +})) + +afterEach(() => { + act(() => testRoot?.unmount()) + testContainer?.remove() + testRoot = undefined + testContainer = undefined + loaderOptions.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 }) +}) + +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', async () => { + importLibrary.mockRejectedValue(new Error('Maps failed')) + + 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', async () => { + importLibrary.mockResolvedValue({ + AutocompleteSessionToken, + AutocompleteSuggestion: { fetchAutocompleteSuggestions }, + }) + + 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(loaderOptions.at(-1)).toEqual({ + apiKey: 'test-key', + version: 'weekly', + }) + 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', '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)) + }) +}) diff --git a/packages/address-autocomplete/components/AddressAutocomplete.tsx b/packages/address-autocomplete/components/AddressAutocomplete.tsx new file mode 100644 index 0000000000..2cd2708c87 --- /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 { ErrorSnackbar } from '@graphcommerce/next-ui' +import { Trans } from '@lingui/react/macro' +import { Box, CircularProgress, ClickAwayListener } from '@mui/material' +import type { ReactNode } 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 { AddressAutocompletePopper } from './AddressAutocompletePopper' + +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 pendingRegion = useRef(null) + const countries = useCountries() + + 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 places = usePlacesAutocomplete({ 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]) + + return ( + <> + {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/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/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/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..7a0b440d05 --- /dev/null +++ b/packages/address-autocomplete/package.json @@ -0,0 +1,39 @@ +{ + "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": { + "@googlemaps/js-api-loader": "^1.16.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", + "@mui/material": "^7.0.0", + "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..51fc2f45ff --- /dev/null +++ b/packages/address-autocomplete/utils/formatAddress.test.ts @@ -0,0 +1,105 @@ +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.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('221B', '221B', ['street_number']), + component('Flat A', 'A', ['subpremise']), + ], + }) + + expect(result.houseNumber).toBe('221B') + expect(result.addition).toBe('A') + }) + + 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..4772687eab --- /dev/null +++ b/packages/address-autocomplete/utils/formatAddress.ts @@ -0,0 +1,76 @@ +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') + const country = getShort('country') + let houseNumber = getShort('street_number') + let addition = getShort('subpremise') + + if (!addition) { + const houseNumberParts = houseNumber.match(/^(\d+(?:[-/]\d+)*)(\p{L}+)$/u) + if (houseNumberParts) [, houseNumber, addition] = houseNumberParts + } + + return { + street: getLong('route'), + houseNumber, + addition, + postcode: postcodeSuffix ? `${postcode}-${postcodeSuffix}` : postcode, + city: getLong('locality') || getLong('postal_town') || getLong('sublocality_level_1'), + country, + region: getLong('administrative_area_level_1'), + regionCode: getShort('administrative_area_level_1'), + } +}