From 588c55fe339aa3c04d64f1d91b6f704319d4272e Mon Sep 17 00:00:00 2001 From: bc-maxy Date: Wed, 17 Jun 2026 17:22:17 +1000 Subject: [PATCH 01/21] fix(checkout): CHECKOUT-10026 migrate Google Places script loader to importLibrary API --- .../GoogleAutocompleteScriptLoader.ts | 65 +++++-------------- .../GoogleAutocompleteService.ts | 2 +- .../getGoogleAutocompleteScriptLoader.test.ts | 51 +++++++-------- .../getGoogleAutocompleteScriptLoader.ts | 2 +- .../googleAutocompleteTypes.ts | 8 +++ .../app/address/googleAutocomplete/index.ts | 2 +- 6 files changed, 52 insertions(+), 78 deletions(-) diff --git a/packages/core/src/app/address/googleAutocomplete/GoogleAutocompleteScriptLoader.ts b/packages/core/src/app/address/googleAutocomplete/GoogleAutocompleteScriptLoader.ts index c74df6d854..25a720848c 100644 --- a/packages/core/src/app/address/googleAutocomplete/GoogleAutocompleteScriptLoader.ts +++ b/packages/core/src/app/address/googleAutocomplete/GoogleAutocompleteScriptLoader.ts @@ -1,59 +1,26 @@ import { getScriptLoader, type ScriptLoader } from '@bigcommerce/script-loader'; -import { type GoogleAutocompleteWindow, type GoogleMapsSdk } from './googleAutocompleteTypes'; +export class GoogleAutocompleteScriptLoader { + private _scriptLoader: ScriptLoader = getScriptLoader(); + private _placesPromise?: Promise; -export default class GoogleAutocompleteScriptLoader { - private _scriptLoader: ScriptLoader; - private _googleAutoComplete?: Promise; - - constructor() { - this._scriptLoader = getScriptLoader(); - } - - loadMapsSdk(apiKey: string): Promise { - if (this._googleAutoComplete) { - return this._googleAutoComplete; + loadPlacesLibrary(apiKey: string): Promise { + if (this._placesPromise) { + return this._placesPromise; } - this._googleAutoComplete = new Promise((resolve, reject) => { - const callbackName = 'initAutoComplete'; - const params = [ - 'language=en', - `key=${apiKey}`, - 'libraries=places', - `callback=${callbackName}`, - ].join('&'); + const params = ['language=en', `key=${apiKey}`, 'loading=async'].join('&'); - (window as GoogleCallbackWindow)[callbackName] = () => { - if (isAutocompleteWindow(window)) { - resolve(window.google.maps); - } + const promise = this._scriptLoader + .loadScript(`//maps.googleapis.com/maps/api/js?${params}`) + .then(() => google.maps.importLibrary('places') as Promise) + .catch((e) => { + this._placesPromise = undefined; + throw e; + }); - reject(new Error('Failed to initialize Google Maps Autocomplete SDK.')); - }; + this._placesPromise = promise; - this._scriptLoader - .loadScript(`//maps.googleapis.com/maps/api/js?${params}`) - .catch((e) => { - this._googleAutoComplete = undefined; - throw e; - }); - }); - - return this._googleAutoComplete; + return promise; } } - -function isAutocompleteWindow(window: Window): window is GoogleAutocompleteWindow { - const autocompleteWindow = window as GoogleAutocompleteWindow; - - return Boolean( - autocompleteWindow.google && - autocompleteWindow.google.maps && - autocompleteWindow.google.maps.places, - ); -} - -export interface GoogleCallbackWindow extends Window { - initAutoComplete?(): void; -} diff --git a/packages/core/src/app/address/googleAutocomplete/GoogleAutocompleteService.ts b/packages/core/src/app/address/googleAutocomplete/GoogleAutocompleteService.ts index ae5ce4840a..7dcd9f4f92 100644 --- a/packages/core/src/app/address/googleAutocomplete/GoogleAutocompleteService.ts +++ b/packages/core/src/app/address/googleAutocomplete/GoogleAutocompleteService.ts @@ -1,5 +1,5 @@ import getGoogleAutocompleteScriptLoader from './getGoogleAutocompleteScriptLoader'; -import type GoogleAutocompleteScriptLoader from './GoogleAutocompleteScriptLoader'; +import type { GoogleAutocompleteScriptLoader } from './GoogleAutocompleteScriptLoader'; export default class GoogleAutocompleteService { private _autocompletePromise?: Promise; diff --git a/packages/core/src/app/address/googleAutocomplete/getGoogleAutocompleteScriptLoader.test.ts b/packages/core/src/app/address/googleAutocomplete/getGoogleAutocompleteScriptLoader.test.ts index 737984d0d6..3ef45dad2f 100644 --- a/packages/core/src/app/address/googleAutocomplete/getGoogleAutocompleteScriptLoader.test.ts +++ b/packages/core/src/app/address/googleAutocomplete/getGoogleAutocompleteScriptLoader.test.ts @@ -1,54 +1,53 @@ import { getScriptLoader, type ScriptLoader } from '@bigcommerce/script-loader'; -import GoogleAutocompleteScriptLoader, { - type GoogleCallbackWindow, -} from './GoogleAutocompleteScriptLoader'; -import { type GoogleAutocompleteWindow } from './googleAutocompleteTypes'; +import { GoogleAutocompleteScriptLoader } from './GoogleAutocompleteScriptLoader'; +import { type WindowWithGoogleMaps } from './googleAutocompleteTypes'; + +const mockPlacesLibrary = {} as google.maps.PlacesLibrary; describe('GoogleAutocompleteScriptLoader', () => { const scriptLoader: ScriptLoader = getScriptLoader(); let googleScriptLoader: GoogleAutocompleteScriptLoader; - describe('#loadMapsSdk()', () => { - let spiedLoadScript: any; + describe('#loadPlacesLibrary()', () => { + let spiedLoadScript: jest.SpyInstance; beforeEach(async () => { spiedLoadScript = jest.spyOn(scriptLoader, 'loadScript'); - spiedLoadScript.mockImplementation(() => { - (window as GoogleAutocompleteWindow).google = { - maps: { - places: {}, - } as any, - }; + spiedLoadScript.mockResolvedValue(undefined); - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - (window as GoogleCallbackWindow).initAutoComplete!(); - }); + (window as WindowWithGoogleMaps).google = { + maps: { + importLibrary: jest.fn().mockResolvedValue(mockPlacesLibrary), + }, + }; googleScriptLoader = new GoogleAutocompleteScriptLoader(); - await googleScriptLoader.loadMapsSdk('foo'); + await googleScriptLoader.loadPlacesLibrary('foo'); }); afterEach(() => { spiedLoadScript.mockReset(); + delete (window as WindowWithGoogleMaps).google; }); it('calls loadScript with the right parameters', () => { expect(scriptLoader.loadScript).toHaveBeenCalledWith( - [ - '//maps.googleapis.com/maps/api/js?language=en', - 'key=foo', - 'libraries=places', - 'callback=initAutoComplete', - ].join('&'), + '//maps.googleapis.com/maps/api/js?language=en&key=foo&loading=async', ); }); - it('calls loadScript once', async () => { - await googleScriptLoader.loadMapsSdk('x'); - await googleScriptLoader.loadMapsSdk('y'); - await googleScriptLoader.loadMapsSdk('z'); + it('resolves with the places library', async () => { + const result = await googleScriptLoader.loadPlacesLibrary('foo'); + + expect(result).toBe(mockPlacesLibrary); + }); + + it('calls loadScript once regardless of how many times loadPlacesLibrary is called', async () => { + await googleScriptLoader.loadPlacesLibrary('x'); + await googleScriptLoader.loadPlacesLibrary('y'); + await googleScriptLoader.loadPlacesLibrary('z'); expect(scriptLoader.loadScript).toHaveBeenCalledTimes(1); }); diff --git a/packages/core/src/app/address/googleAutocomplete/getGoogleAutocompleteScriptLoader.ts b/packages/core/src/app/address/googleAutocomplete/getGoogleAutocompleteScriptLoader.ts index f14f2d5ed6..fe7164ef2d 100644 --- a/packages/core/src/app/address/googleAutocomplete/getGoogleAutocompleteScriptLoader.ts +++ b/packages/core/src/app/address/googleAutocomplete/getGoogleAutocompleteScriptLoader.ts @@ -1,4 +1,4 @@ -import GoogleAutocompleteScriptLoader from './GoogleAutocompleteScriptLoader'; +import { GoogleAutocompleteScriptLoader } from './GoogleAutocompleteScriptLoader'; let instance: GoogleAutocompleteScriptLoader; diff --git a/packages/core/src/app/address/googleAutocomplete/googleAutocompleteTypes.ts b/packages/core/src/app/address/googleAutocomplete/googleAutocompleteTypes.ts index d78c8f72d3..c408e09624 100644 --- a/packages/core/src/app/address/googleAutocomplete/googleAutocompleteTypes.ts +++ b/packages/core/src/app/address/googleAutocomplete/googleAutocompleteTypes.ts @@ -39,6 +39,14 @@ export interface GoogleAutocompleteWindow extends Window { }; } +export interface WindowWithGoogleMaps extends Window { + google?: { + maps: { + importLibrary: (libraryName: string) => Promise; + }; + }; +} + export type GoogleAddressFieldType = | 'postal_town' | 'administrative_area_level_1' diff --git a/packages/core/src/app/address/googleAutocomplete/index.ts b/packages/core/src/app/address/googleAutocomplete/index.ts index 5114317360..463ee8df88 100644 --- a/packages/core/src/app/address/googleAutocomplete/index.ts +++ b/packages/core/src/app/address/googleAutocomplete/index.ts @@ -1,5 +1,5 @@ export * from './googleAutocompleteTypes'; export { default as mapToAddress } from './mapToAddress'; -export { default as GoogleAutocompleteScriptLoader } from './GoogleAutocompleteScriptLoader'; +export { GoogleAutocompleteScriptLoader } from './GoogleAutocompleteScriptLoader'; export { default as GoogleAutocomplete } from './GoogleAutocomplete'; export { default as GoogleAutocompleteFormField } from './GoogleAutocompleteFormField'; From bd75dcc7cf405feb07106369037ac7476bb72365 Mon Sep 17 00:00:00 2001 From: bc-maxy Date: Wed, 17 Jun 2026 17:33:10 +1000 Subject: [PATCH 02/21] fix(checkout): CHECKOUT-10026 Migrate GoogleAutocompleteService --- .../GoogleAutocompleteService.ts | 57 +++++++++---------- 1 file changed, 27 insertions(+), 30 deletions(-) diff --git a/packages/core/src/app/address/googleAutocomplete/GoogleAutocompleteService.ts b/packages/core/src/app/address/googleAutocomplete/GoogleAutocompleteService.ts index 7dcd9f4f92..31d627bf36 100644 --- a/packages/core/src/app/address/googleAutocomplete/GoogleAutocompleteService.ts +++ b/packages/core/src/app/address/googleAutocomplete/GoogleAutocompleteService.ts @@ -1,46 +1,43 @@ import getGoogleAutocompleteScriptLoader from './getGoogleAutocompleteScriptLoader'; -import type { GoogleAutocompleteScriptLoader } from './GoogleAutocompleteScriptLoader'; +import { type GoogleAutocompleteScriptLoader } from './GoogleAutocompleteScriptLoader'; export default class GoogleAutocompleteService { - private _autocompletePromise?: Promise; - private _placesPromise?: Promise; - constructor( private _apiKey: string, private _scriptLoader: GoogleAutocompleteScriptLoader = getGoogleAutocompleteScriptLoader(), ) {} - getAutocompleteService(): Promise { - if (!this._autocompletePromise) { - this._autocompletePromise = this._scriptLoader - .loadMapsSdk(this._apiKey) - .then((googleMapsSdk) => { - if (!googleMapsSdk.places.AutocompleteService) { - throw new Error('`AutocompleteService` is undefined'); - } - - return new googleMapsSdk.places.AutocompleteService(); - }); - } + private convertToRegionCodes( + country: string | string[] | null | undefined, + ): string[] | undefined { + if (!country) return undefined; - return this._autocompletePromise; + return Array.isArray(country) ? country : [country]; } - getPlacesServices(): Promise { - const node = document.createElement('div'); + async getSuggestions( + input: string, + types: string[], + componentRestrictions?: google.maps.places.ComponentRestrictions, + ): Promise { + const { AutocompleteSuggestion } = await this._scriptLoader.loadPlacesLibrary(this._apiKey); + const includedRegionCodes = this.convertToRegionCodes(componentRestrictions?.country); + + const { suggestions } = await AutocompleteSuggestion.fetchAutocompleteSuggestions({ + input, + includedPrimaryTypes: types, + includedRegionCodes, + }); + + return suggestions; + } - if (!this._placesPromise) { - this._placesPromise = this._scriptLoader - .loadMapsSdk(this._apiKey) - .then((googleMapsSdk) => { - if (!googleMapsSdk.places.PlacesService) { - throw new Error('`PlacesService` is undefined'); - } + async getPlaceDetails(placeId: string, fields: string[]): Promise { + const { Place } = await this._scriptLoader.loadPlacesLibrary(this._apiKey); + const place = new Place({ id: placeId }); - return new googleMapsSdk.places.PlacesService(node); - }); - } + await place.fetchFields({ fields }); - return this._placesPromise; + return place; } } From a9876e5fe81a97b801f3679648c361bdddac1ea1 Mon Sep 17 00:00:00 2001 From: bc-maxy Date: Fri, 19 Jun 2026 10:12:06 +1000 Subject: [PATCH 03/21] fix(checkout): CHECKOUT-10026 Update GoogleAutocomplete file --- .../googleAutocomplete/GoogleAutocomplete.tsx | 67 +++++++------------ .../googleAutocompleteTypes.ts | 39 ----------- .../address/googleAutocomplete/utils.test.ts | 62 +++++++++++++++++ .../app/address/googleAutocomplete/utils.ts | 32 +++++++++ 4 files changed, 117 insertions(+), 83 deletions(-) create mode 100644 packages/core/src/app/address/googleAutocomplete/utils.test.ts create mode 100644 packages/core/src/app/address/googleAutocomplete/utils.ts diff --git a/packages/core/src/app/address/googleAutocomplete/GoogleAutocomplete.tsx b/packages/core/src/app/address/googleAutocomplete/GoogleAutocomplete.tsx index ebc4c45343..528bcdc4e7 100644 --- a/packages/core/src/app/address/googleAutocomplete/GoogleAutocomplete.tsx +++ b/packages/core/src/app/address/googleAutocomplete/GoogleAutocomplete.tsx @@ -5,6 +5,7 @@ import { Autocomplete, type AutocompleteItem } from '@bigcommerce/checkout/ui'; import GoogleAutocompleteService from './GoogleAutocompleteService'; import { type GoogleAutocompleteOptionTypes } from './googleAutocompleteTypes'; +import { mapToAutocompleteItems, mapToGeocoderAddressComponent } from './utils'; import './GoogleAutocomplete.scss'; export interface GoogleAutocompleteProps { @@ -21,17 +22,6 @@ export interface GoogleAutocompleteProps { onChange?(value: string, isOpen: boolean): void; } -const toAutocompleteItems = ( - results?: google.maps.places.AutocompletePrediction[], -): AutocompleteItem[] => { - return (results || []).map((result) => ({ - label: result.description, - value: result.structured_formatting.main_text, - highlightedSlices: result.matched_substrings, - id: result.place_id, - })); -}; - const GoogleAutocomplete: React.FC = ({ initialValue, onToggleOpen = noop, @@ -53,26 +43,25 @@ const GoogleAutocomplete: React.FC = ({ googleAutocompleteServiceRef.current = new GoogleAutocompleteService(apiKey); } - const onSelectHandler = (item: AutocompleteItem) => { + const handleSelect = (item: AutocompleteItem) => { const service = googleAutocompleteServiceRef.current; if (!service) return; - service.getPlacesServices().then((placesService) => { - placesService.getDetails( - { - placeId: item.id, - fields: fields || ['address_components', 'name'], - }, - (result) => { - if (nextElement) { - nextElement.focus(); - } - - onSelect(result, item); - }, - ); - }); + service + .getPlaceDetails(item.id, fields || ['addressComponents', 'displayName']) + .then((place) => { + const placeResult: google.maps.places.PlaceResult = { + address_components: place.addressComponents?.map(mapToGeocoderAddressComponent), + name: place.displayName ?? '', + }; + + if (nextElement) { + nextElement.focus(); + } + + onSelect(placeResult, item); + }); }; const resetAutocomplete = (): void => { @@ -95,23 +84,13 @@ const GoogleAutocomplete: React.FC = ({ if (!service) return; - service.getAutocompleteService().then((autocompleteService) => { - autocompleteService.getPlacePredictions( - { - input, - types: types || ['geocode'], - componentRestrictions, - }, - (results) => { - const autocompleteItems = toAutocompleteItems(results ?? undefined); - - setItems(autocompleteItems); - }, - ); - }); + service + .getSuggestions(input, types || ['geocode'], componentRestrictions) + .then((suggestions) => setItems(mapToAutocompleteItems(suggestions))) + .catch(() => setItems([])); }; - const onChangeHandler = (input: string) => { + const handleChange = (input: string) => { onChange(input, false); if (!isAutocompleteEnabled) { @@ -133,8 +112,8 @@ const GoogleAutocomplete: React.FC = ({ }} items={items} listTestId="address-autocomplete-suggestions" - onChange={onChangeHandler} - onSelect={onSelectHandler} + onChange={handleChange} + onSelect={handleSelect} onToggleOpen={onToggleOpen} >
diff --git a/packages/core/src/app/address/googleAutocomplete/googleAutocompleteTypes.ts b/packages/core/src/app/address/googleAutocomplete/googleAutocompleteTypes.ts index c408e09624..6ea324795f 100644 --- a/packages/core/src/app/address/googleAutocomplete/googleAutocompleteTypes.ts +++ b/packages/core/src/app/address/googleAutocomplete/googleAutocompleteTypes.ts @@ -1,44 +1,5 @@ export type GoogleAutocompleteOptionTypes = 'establishment' | 'geocode' | 'address'; -export type GoogleAutocompleteFields = - | 'address_components' - | 'adr_address' - | 'aspects' - | 'formatted_address' - | 'formatted_phone_number' - | 'geometry' - | 'html_attributions' - | 'icon' - | 'international_phone_number' - | 'name' - | 'opening_hours' - | 'photos' - | 'place_id' - | 'plus_code' - | 'price_level' - | 'rating' - | 'reviews' - | 'types' - | 'url' - | 'utc_offset' - | 'vicinity' - | 'website'; - -export type GoogleAutocompleteEvent = 'place_changed'; - -export interface GoogleMapsSdk { - places: { - AutocompleteService?: new () => google.maps.places.AutocompleteService; - PlacesService?: new (attrContainer: HTMLDivElement) => google.maps.places.PlacesService; - }; -} - -export interface GoogleAutocompleteWindow extends Window { - google: { - maps: GoogleMapsSdk; - }; -} - export interface WindowWithGoogleMaps extends Window { google?: { maps: { diff --git a/packages/core/src/app/address/googleAutocomplete/utils.test.ts b/packages/core/src/app/address/googleAutocomplete/utils.test.ts new file mode 100644 index 0000000000..ad09500ba1 --- /dev/null +++ b/packages/core/src/app/address/googleAutocomplete/utils.test.ts @@ -0,0 +1,62 @@ +import { mapToAutocompleteItems, mapToGeocoderAddressComponent } from './utils'; + +describe('mapToAutocompleteItems', () => { + it('maps a suggestion to an autocomplete item', () => { + const mockSuggestions: google.maps.places.AutocompleteSuggestion[] = [ + { + placePrediction: { + placeId: 'place-1', + text: { + text: '123 Main St, New York', + matches: [{ startOffset: 0, endOffset: 3 }], + }, + mainText: { text: '123 Main St' }, + } as google.maps.places.PlacePrediction, + }, + ]; + + const items = mapToAutocompleteItems(mockSuggestions); + + expect(items).toHaveLength(1); + expect(items[0]).toMatchObject({ + id: 'place-1', + label: '123 Main St, New York', + value: '123 Main St', + highlightedSlices: [{ offset: 0, length: 3 }], + }); + }); + + it('skips suggestions without placePrediction', () => { + const mockSuggestionsWothoutPlacePrediction: google.maps.places.AutocompleteSuggestion[] = [ + { placePrediction: null }, + ]; + + expect(mapToAutocompleteItems(mockSuggestionsWothoutPlacePrediction)).toEqual([]); + }); +}); + +describe('mapToGeocoderAddressComponent', () => { + it('maps an AddressComponent to a GeocoderAddressComponent', () => { + const addressComponentMock = { + longText: 'New York', + shortText: 'NY', + types: ['locality', 'political'], + } as google.maps.places.AddressComponent; + + expect(mapToGeocoderAddressComponent(addressComponentMock)).toEqual({ + long_name: 'New York', + short_name: 'NY', + types: ['locality', 'political'], + }); + }); + + it('falls back to empty strings and empty array when fields are missing', () => { + const component = {} as google.maps.places.AddressComponent; + + expect(mapToGeocoderAddressComponent(component)).toEqual({ + long_name: '', + short_name: '', + types: [], + }); + }); +}); diff --git a/packages/core/src/app/address/googleAutocomplete/utils.ts b/packages/core/src/app/address/googleAutocomplete/utils.ts new file mode 100644 index 0000000000..fcb76fe478 --- /dev/null +++ b/packages/core/src/app/address/googleAutocomplete/utils.ts @@ -0,0 +1,32 @@ +import { type AutocompleteItem } from '@bigcommerce/checkout/ui'; + +export const mapToAutocompleteItems = ( + suggestions: google.maps.places.AutocompleteSuggestion[], +): AutocompleteItem[] => + suggestions.flatMap((suggestion) => { + const { placePrediction } = suggestion; + + if (!placePrediction) return []; + + return [ + { + label: placePrediction.text.text, + value: placePrediction.mainText?.text ?? '', + highlightedSlices: placePrediction.text.matches.map( + ({ startOffset, endOffset }) => ({ + offset: startOffset, + length: endOffset - startOffset, + }), + ), + id: placePrediction.placeId, + }, + ]; + }); + +export const mapToGeocoderAddressComponent = ( + c: google.maps.places.AddressComponent, +): google.maps.GeocoderAddressComponent => ({ + long_name: c.longText ?? '', + short_name: c.shortText ?? '', + types: c.types ?? [], +}); From 1322a5ae70a7242fc88cdd4fa529d5c3ef421bfd Mon Sep 17 00:00:00 2001 From: bc-maxy Date: Fri, 19 Jun 2026 10:42:20 +1000 Subject: [PATCH 04/21] fix(checkout): CHECKOUT-10026 Various quick fixes --- .../googleAutocomplete/GoogleAutocomplete.tsx | 3 +- .../GoogleAutocompleteService.test.ts | 96 +++++++++++++++++++ .../getGoogleAutocompleteScriptLoader.test.ts | 9 +- .../googleAutocompleteTypes.ts | 8 -- .../address/googleAutocomplete/utils.test.ts | 4 +- .../app/address/googleAutocomplete/utils.ts | 2 +- 6 files changed, 109 insertions(+), 13 deletions(-) create mode 100644 packages/core/src/app/address/googleAutocomplete/GoogleAutocompleteService.test.ts diff --git a/packages/core/src/app/address/googleAutocomplete/GoogleAutocomplete.tsx b/packages/core/src/app/address/googleAutocomplete/GoogleAutocomplete.tsx index 528bcdc4e7..65b715d431 100644 --- a/packages/core/src/app/address/googleAutocomplete/GoogleAutocomplete.tsx +++ b/packages/core/src/app/address/googleAutocomplete/GoogleAutocomplete.tsx @@ -61,7 +61,8 @@ const GoogleAutocomplete: React.FC = ({ } onSelect(placeResult, item); - }); + }) + .catch(() => resetAutocomplete()); }; const resetAutocomplete = (): void => { diff --git a/packages/core/src/app/address/googleAutocomplete/GoogleAutocompleteService.test.ts b/packages/core/src/app/address/googleAutocomplete/GoogleAutocompleteService.test.ts new file mode 100644 index 0000000000..1cda91f473 --- /dev/null +++ b/packages/core/src/app/address/googleAutocomplete/GoogleAutocompleteService.test.ts @@ -0,0 +1,96 @@ +import { type GoogleAutocompleteScriptLoader } from './GoogleAutocompleteScriptLoader'; +import GoogleAutocompleteService from './GoogleAutocompleteService'; + +const mockSuggestions = [ + { placePrediction: { placeId: 'place-1' } }, +] as google.maps.places.AutocompleteSuggestion[]; + +const mockPlace = { + fetchFields: jest.fn().mockResolvedValue(undefined), + addressComponents: [], + displayName: '123 Main St', +} as google.maps.places.Place; + +const mockPlacesLibrary = { + AutocompleteSuggestion: { + fetchAutocompleteSuggestions: jest.fn().mockResolvedValue({ suggestions: mockSuggestions }), + }, + Place: jest.fn().mockImplementation(() => mockPlace), +} as google.maps.PlacesLibrary; + +const mockScriptLoader = { + loadPlacesLibrary: jest.fn().mockResolvedValue(mockPlacesLibrary), +} as unknown as GoogleAutocompleteScriptLoader; + +describe('GoogleAutocompleteService', () => { + let service: GoogleAutocompleteService; + + beforeEach(() => { + jest.clearAllMocks(); + service = new GoogleAutocompleteService('test-api-key', mockScriptLoader); + }); + + describe('#getSuggestions()', () => { + it('returns suggestions from the API', async () => { + const result = await service.getSuggestions('123 Main', ['address']); + + expect(result).toBe(mockSuggestions); + }); + + it('passes input and types to fetchAutocompleteSuggestions', async () => { + await service.getSuggestions('123 Main', ['address']); + + expect( + mockPlacesLibrary.AutocompleteSuggestion.fetchAutocompleteSuggestions, + ).toHaveBeenCalledWith( + expect.objectContaining({ input: '123 Main', includedPrimaryTypes: ['address'] }), + ); + }); + + it('maps a string country restriction to includedRegionCodes array', async () => { + await service.getSuggestions('123 Main', ['address'], { country: 'US' }); + + expect( + mockPlacesLibrary.AutocompleteSuggestion.fetchAutocompleteSuggestions, + ).toHaveBeenCalledWith(expect.objectContaining({ includedRegionCodes: ['US'] })); + }); + + it('maps an array country restriction to includedRegionCodes', async () => { + await service.getSuggestions('123 Main', ['address'], { country: ['US', 'CA'] }); + + expect( + mockPlacesLibrary.AutocompleteSuggestion.fetchAutocompleteSuggestions, + ).toHaveBeenCalledWith(expect.objectContaining({ includedRegionCodes: ['US', 'CA'] })); + }); + + it('omits includedRegionCodes when no componentRestrictions provided', async () => { + await service.getSuggestions('123 Main', ['address']); + + expect( + mockPlacesLibrary.AutocompleteSuggestion.fetchAutocompleteSuggestions, + ).toHaveBeenCalledWith(expect.objectContaining({ includedRegionCodes: undefined })); + }); + }); + + describe('#getPlaceDetails()', () => { + it('constructs a Place with the given placeId', async () => { + await service.getPlaceDetails('place-1', ['addressComponents', 'displayName']); + + expect(mockPlacesLibrary.Place).toHaveBeenCalledWith({ id: 'place-1' }); + }); + + it('calls fetchFields with the given fields', async () => { + await service.getPlaceDetails('place-1', ['addressComponents', 'displayName']); + + expect(mockPlace.fetchFields).toHaveBeenCalledWith({ + fields: ['addressComponents', 'displayName'], + }); + }); + + it('returns the place after fetching fields', async () => { + const result = await service.getPlaceDetails('place-1', ['addressComponents']); + + expect(result).toBe(mockPlace); + }); + }); +}); diff --git a/packages/core/src/app/address/googleAutocomplete/getGoogleAutocompleteScriptLoader.test.ts b/packages/core/src/app/address/googleAutocomplete/getGoogleAutocompleteScriptLoader.test.ts index 3ef45dad2f..63c62f345a 100644 --- a/packages/core/src/app/address/googleAutocomplete/getGoogleAutocompleteScriptLoader.test.ts +++ b/packages/core/src/app/address/googleAutocomplete/getGoogleAutocompleteScriptLoader.test.ts @@ -1,7 +1,14 @@ import { getScriptLoader, type ScriptLoader } from '@bigcommerce/script-loader'; import { GoogleAutocompleteScriptLoader } from './GoogleAutocompleteScriptLoader'; -import { type WindowWithGoogleMaps } from './googleAutocompleteTypes'; + +interface WindowWithGoogleMaps extends Window { + google?: { + maps: { + importLibrary: (libraryName: string) => Promise; + }; + }; +} const mockPlacesLibrary = {} as google.maps.PlacesLibrary; diff --git a/packages/core/src/app/address/googleAutocomplete/googleAutocompleteTypes.ts b/packages/core/src/app/address/googleAutocomplete/googleAutocompleteTypes.ts index 6ea324795f..9a7b2f27d2 100644 --- a/packages/core/src/app/address/googleAutocomplete/googleAutocompleteTypes.ts +++ b/packages/core/src/app/address/googleAutocomplete/googleAutocompleteTypes.ts @@ -1,13 +1,5 @@ export type GoogleAutocompleteOptionTypes = 'establishment' | 'geocode' | 'address'; -export interface WindowWithGoogleMaps extends Window { - google?: { - maps: { - importLibrary: (libraryName: string) => Promise; - }; - }; -} - export type GoogleAddressFieldType = | 'postal_town' | 'administrative_area_level_1' diff --git a/packages/core/src/app/address/googleAutocomplete/utils.test.ts b/packages/core/src/app/address/googleAutocomplete/utils.test.ts index ad09500ba1..d8574ad3ea 100644 --- a/packages/core/src/app/address/googleAutocomplete/utils.test.ts +++ b/packages/core/src/app/address/googleAutocomplete/utils.test.ts @@ -27,11 +27,11 @@ describe('mapToAutocompleteItems', () => { }); it('skips suggestions without placePrediction', () => { - const mockSuggestionsWothoutPlacePrediction: google.maps.places.AutocompleteSuggestion[] = [ + const mockSuggestionsWithoutPlacePrediction: google.maps.places.AutocompleteSuggestion[] = [ { placePrediction: null }, ]; - expect(mapToAutocompleteItems(mockSuggestionsWothoutPlacePrediction)).toEqual([]); + expect(mapToAutocompleteItems(mockSuggestionsWithoutPlacePrediction)).toEqual([]); }); }); diff --git a/packages/core/src/app/address/googleAutocomplete/utils.ts b/packages/core/src/app/address/googleAutocomplete/utils.ts index fcb76fe478..ae3baa642d 100644 --- a/packages/core/src/app/address/googleAutocomplete/utils.ts +++ b/packages/core/src/app/address/googleAutocomplete/utils.ts @@ -12,7 +12,7 @@ export const mapToAutocompleteItems = ( { label: placePrediction.text.text, value: placePrediction.mainText?.text ?? '', - highlightedSlices: placePrediction.text.matches.map( + highlightedSlices: (placePrediction.text.matches ?? []).map( ({ startOffset, endOffset }) => ({ offset: startOffset, length: endOffset - startOffset, From f7af121225e35cede0ef8bd3a8b95773e9d60e7a Mon Sep 17 00:00:00 2001 From: bc-maxy Date: Fri, 19 Jun 2026 14:47:40 +1000 Subject: [PATCH 05/21] fix(checkout): CHECKOUT-10026 Resolve cursor comments --- .../googleAutocomplete/GoogleAutocomplete.tsx | 22 ++++++++++---- .../GoogleAutocompleteFormField.tsx | 2 +- .../GoogleAutocompleteService.ts | 2 +- .../app/address/googleAutocomplete/index.ts | 2 +- .../address/googleAutocomplete/utils.test.ts | 28 +++++++++++++++++- .../app/address/googleAutocomplete/utils.ts | 29 +++++++++++++++++-- 6 files changed, 73 insertions(+), 12 deletions(-) diff --git a/packages/core/src/app/address/googleAutocomplete/GoogleAutocomplete.tsx b/packages/core/src/app/address/googleAutocomplete/GoogleAutocomplete.tsx index 65b715d431..b3cba8855e 100644 --- a/packages/core/src/app/address/googleAutocomplete/GoogleAutocomplete.tsx +++ b/packages/core/src/app/address/googleAutocomplete/GoogleAutocomplete.tsx @@ -5,7 +5,11 @@ import { Autocomplete, type AutocompleteItem } from '@bigcommerce/checkout/ui'; import GoogleAutocompleteService from './GoogleAutocompleteService'; import { type GoogleAutocompleteOptionTypes } from './googleAutocompleteTypes'; -import { mapToAutocompleteItems, mapToGeocoderAddressComponent } from './utils'; +import { + mapToAutocompleteItems, + mapToGeocoderAddressComponent, + mapToIncludedPrimaryTypes, +} from './utils'; import './GoogleAutocomplete.scss'; export interface GoogleAutocompleteProps { @@ -22,7 +26,7 @@ export interface GoogleAutocompleteProps { onChange?(value: string, isOpen: boolean): void; } -const GoogleAutocomplete: React.FC = ({ +export const GoogleAutocomplete: React.FC = ({ initialValue, onToggleOpen = noop, inputProps = {}, @@ -62,7 +66,11 @@ const GoogleAutocomplete: React.FC = ({ onSelect(placeResult, item); }) - .catch(() => resetAutocomplete()); + .catch(() => { + // keep user suggestion text if api call fails + onSelect({ name: item.label }, item); + resetAutocomplete(); + }); }; const resetAutocomplete = (): void => { @@ -86,7 +94,11 @@ const GoogleAutocomplete: React.FC = ({ if (!service) return; service - .getSuggestions(input, types || ['geocode'], componentRestrictions) + .getSuggestions( + input, + mapToIncludedPrimaryTypes(types || ['geocode']), + componentRestrictions, + ) .then((suggestions) => setItems(mapToAutocompleteItems(suggestions))) .catch(() => setItems([])); }; @@ -121,5 +133,3 @@ const GoogleAutocomplete: React.FC = ({ ); }; - -export default GoogleAutocomplete; diff --git a/packages/core/src/app/address/googleAutocomplete/GoogleAutocompleteFormField.tsx b/packages/core/src/app/address/googleAutocomplete/GoogleAutocompleteFormField.tsx index 2fec8edc00..55d2e4b60d 100644 --- a/packages/core/src/app/address/googleAutocomplete/GoogleAutocompleteFormField.tsx +++ b/packages/core/src/app/address/googleAutocomplete/GoogleAutocompleteFormField.tsx @@ -11,7 +11,7 @@ import { getAddressFormFieldLabelId, } from '../getAddressFormFieldInputId'; -import GoogleAutocomplete from './GoogleAutocomplete'; +import { GoogleAutocomplete } from './GoogleAutocomplete'; export interface GoogleAutocompleteFormFieldProps { apiKey: string; diff --git a/packages/core/src/app/address/googleAutocomplete/GoogleAutocompleteService.ts b/packages/core/src/app/address/googleAutocomplete/GoogleAutocompleteService.ts index 31d627bf36..77667888bc 100644 --- a/packages/core/src/app/address/googleAutocomplete/GoogleAutocompleteService.ts +++ b/packages/core/src/app/address/googleAutocomplete/GoogleAutocompleteService.ts @@ -17,7 +17,7 @@ export default class GoogleAutocompleteService { async getSuggestions( input: string, - types: string[], + types: string[] | undefined, componentRestrictions?: google.maps.places.ComponentRestrictions, ): Promise { const { AutocompleteSuggestion } = await this._scriptLoader.loadPlacesLibrary(this._apiKey); diff --git a/packages/core/src/app/address/googleAutocomplete/index.ts b/packages/core/src/app/address/googleAutocomplete/index.ts index 463ee8df88..cf5eaebea1 100644 --- a/packages/core/src/app/address/googleAutocomplete/index.ts +++ b/packages/core/src/app/address/googleAutocomplete/index.ts @@ -1,5 +1,5 @@ export * from './googleAutocompleteTypes'; export { default as mapToAddress } from './mapToAddress'; export { GoogleAutocompleteScriptLoader } from './GoogleAutocompleteScriptLoader'; -export { default as GoogleAutocomplete } from './GoogleAutocomplete'; +export { GoogleAutocomplete } from './GoogleAutocomplete'; export { default as GoogleAutocompleteFormField } from './GoogleAutocompleteFormField'; diff --git a/packages/core/src/app/address/googleAutocomplete/utils.test.ts b/packages/core/src/app/address/googleAutocomplete/utils.test.ts index d8574ad3ea..e9bf9fcc5d 100644 --- a/packages/core/src/app/address/googleAutocomplete/utils.test.ts +++ b/packages/core/src/app/address/googleAutocomplete/utils.test.ts @@ -1,4 +1,30 @@ -import { mapToAutocompleteItems, mapToGeocoderAddressComponent } from './utils'; +import { + mapToAutocompleteItems, + mapToGeocoderAddressComponent, + mapToIncludedPrimaryTypes, +} from './utils'; + +describe('mapToIncludedPrimaryTypes', () => { + it('maps "address" to "street_address"', () => { + expect(mapToIncludedPrimaryTypes(['address'])).toEqual(['street_address']); + }); + + it('keeps "establishment" as-is', () => { + expect(mapToIncludedPrimaryTypes(['establishment'])).toEqual(['establishment']); + }); + + it('returns undefined for "geocode" (no direct equivalent)', () => { + expect(mapToIncludedPrimaryTypes(['geocode'])).toBeUndefined(); + }); + + it('filters out null-mapped types and returns the rest', () => { + expect(mapToIncludedPrimaryTypes(['geocode', 'address'])).toEqual(['street_address']); + }); + + it('returns undefined for an empty array', () => { + expect(mapToIncludedPrimaryTypes([])).toBeUndefined(); + }); +}); describe('mapToAutocompleteItems', () => { it('maps a suggestion to an autocomplete item', () => { diff --git a/packages/core/src/app/address/googleAutocomplete/utils.ts b/packages/core/src/app/address/googleAutocomplete/utils.ts index ae3baa642d..5c18745bfe 100644 --- a/packages/core/src/app/address/googleAutocomplete/utils.ts +++ b/packages/core/src/app/address/googleAutocomplete/utils.ts @@ -1,5 +1,30 @@ import { type AutocompleteItem } from '@bigcommerce/checkout/ui'; +import { type GoogleAutocompleteOptionTypes } from './googleAutocompleteTypes'; + +/** + * Maps legacy Places API collection filters to valid Table A/B primary types + * accepted by the new AutocompleteSuggestion API. + * + * - 'geocode' has no direct equivalent; omitting includedPrimaryTypes gives + * broad address/region results — the same intent as the legacy filter. + * - 'address' maps to 'street_address' for address-only results. + * - 'establishment' remains valid in Table B. + */ +const LEGACY_TYPE_MAP: Record = { + geocode: null, + address: 'street_address', + establishment: 'establishment', +}; + +export const mapToIncludedPrimaryTypes = ( + types: GoogleAutocompleteOptionTypes[], +): string[] | undefined => { + const mapped = types.map((t) => LEGACY_TYPE_MAP[t]).filter((t): t is string => t !== null); + + return mapped.length > 0 ? mapped : undefined; +}; + export const mapToAutocompleteItems = ( suggestions: google.maps.places.AutocompleteSuggestion[], ): AutocompleteItem[] => @@ -10,9 +35,9 @@ export const mapToAutocompleteItems = ( return [ { - label: placePrediction.text.text, + label: placePrediction.text?.text ?? '', value: placePrediction.mainText?.text ?? '', - highlightedSlices: (placePrediction.text.matches ?? []).map( + highlightedSlices: (placePrediction.text?.matches ?? []).map( ({ startOffset, endOffset }) => ({ offset: startOffset, length: endOffset - startOffset, From a5d1b4bcd3274a2c5ac6073dfef2d04bd6a2ce17 Mon Sep 17 00:00:00 2001 From: bc-maxy Date: Fri, 19 Jun 2026 17:12:52 +1000 Subject: [PATCH 06/21] fix(checkout): CHECKOUT-10026 Add session token to suggestion calls --- .../GoogleAutocompleteService.test.ts | 45 ++++++++++++++++++- .../GoogleAutocompleteService.ts | 24 ++++++++-- 2 files changed, 63 insertions(+), 6 deletions(-) diff --git a/packages/core/src/app/address/googleAutocomplete/GoogleAutocompleteService.test.ts b/packages/core/src/app/address/googleAutocomplete/GoogleAutocompleteService.test.ts index 1cda91f473..74a59d59ac 100644 --- a/packages/core/src/app/address/googleAutocomplete/GoogleAutocompleteService.test.ts +++ b/packages/core/src/app/address/googleAutocomplete/GoogleAutocompleteService.test.ts @@ -1,5 +1,5 @@ import { type GoogleAutocompleteScriptLoader } from './GoogleAutocompleteScriptLoader'; -import GoogleAutocompleteService from './GoogleAutocompleteService'; +import { GoogleAutocompleteService } from './GoogleAutocompleteService'; const mockSuggestions = [ { placePrediction: { placeId: 'place-1' } }, @@ -11,10 +11,13 @@ const mockPlace = { displayName: '123 Main St', } as google.maps.places.Place; +const mockSessionToken = {} as google.maps.places.AutocompleteSessionToken; + const mockPlacesLibrary = { AutocompleteSuggestion: { fetchAutocompleteSuggestions: jest.fn().mockResolvedValue({ suggestions: mockSuggestions }), }, + AutocompleteSessionToken: jest.fn().mockImplementation(() => mockSessionToken), Place: jest.fn().mockImplementation(() => mockPlace), } as google.maps.PlacesLibrary; @@ -70,6 +73,23 @@ describe('GoogleAutocompleteService', () => { mockPlacesLibrary.AutocompleteSuggestion.fetchAutocompleteSuggestions, ).toHaveBeenCalledWith(expect.objectContaining({ includedRegionCodes: undefined })); }); + + it('creates a session token on the first call and passes it to the API', async () => { + await service.getSuggestions('123 Main', ['address']); + + expect(mockPlacesLibrary.AutocompleteSessionToken).toHaveBeenCalledTimes(1); + expect( + mockPlacesLibrary.AutocompleteSuggestion.fetchAutocompleteSuggestions, + ).toHaveBeenCalledWith(expect.objectContaining({ sessionToken: mockSessionToken })); + }); + + it('reuses the same session token across multiple calls', async () => { + await service.getSuggestions('1', ['address']); + await service.getSuggestions('12', ['address']); + await service.getSuggestions('123', ['address']); + + expect(mockPlacesLibrary.AutocompleteSessionToken).toHaveBeenCalledTimes(1); + }); }); describe('#getPlaceDetails()', () => { @@ -79,14 +99,35 @@ describe('GoogleAutocompleteService', () => { expect(mockPlacesLibrary.Place).toHaveBeenCalledWith({ id: 'place-1' }); }); - it('calls fetchFields with the given fields', async () => { + it('calls fetchFields with the given fields and the active session token', async () => { + await service.getSuggestions('123 Main', ['address']); await service.getPlaceDetails('place-1', ['addressComponents', 'displayName']); expect(mockPlace.fetchFields).toHaveBeenCalledWith({ fields: ['addressComponents', 'displayName'], + sessionToken: mockSessionToken, }); }); + it('calls fetchFields with no session token if getSuggestions was never called', async () => { + await service.getPlaceDetails('place-1', ['addressComponents', 'displayName']); + + expect(mockPlace.fetchFields).toHaveBeenCalledWith({ + fields: ['addressComponents', 'displayName'], + sessionToken: undefined, + }); + }); + + it('resets the session token after getPlaceDetails so the next session gets a fresh token', async () => { + await service.getSuggestions('123 Main', ['address']); + await service.getPlaceDetails('place-1', ['addressComponents']); + + // Start a new typing session + await service.getSuggestions('456 Oak', ['address']); + + expect(mockPlacesLibrary.AutocompleteSessionToken).toHaveBeenCalledTimes(2); + }); + it('returns the place after fetching fields', async () => { const result = await service.getPlaceDetails('place-1', ['addressComponents']); diff --git a/packages/core/src/app/address/googleAutocomplete/GoogleAutocompleteService.ts b/packages/core/src/app/address/googleAutocomplete/GoogleAutocompleteService.ts index 77667888bc..d6a3fcc82c 100644 --- a/packages/core/src/app/address/googleAutocomplete/GoogleAutocompleteService.ts +++ b/packages/core/src/app/address/googleAutocomplete/GoogleAutocompleteService.ts @@ -1,7 +1,9 @@ -import getGoogleAutocompleteScriptLoader from './getGoogleAutocompleteScriptLoader'; +import { getGoogleAutocompleteScriptLoader } from './getGoogleAutocompleteScriptLoader'; import { type GoogleAutocompleteScriptLoader } from './GoogleAutocompleteScriptLoader'; -export default class GoogleAutocompleteService { +export class GoogleAutocompleteService { + private _sessionToken?: google.maps.places.AutocompleteSessionToken; + constructor( private _apiKey: string, private _scriptLoader: GoogleAutocompleteScriptLoader = getGoogleAutocompleteScriptLoader(), @@ -20,13 +22,20 @@ export default class GoogleAutocompleteService { types: string[] | undefined, componentRestrictions?: google.maps.places.ComponentRestrictions, ): Promise { - const { AutocompleteSuggestion } = await this._scriptLoader.loadPlacesLibrary(this._apiKey); + const { AutocompleteSuggestion, AutocompleteSessionToken } = + await this._scriptLoader.loadPlacesLibrary(this._apiKey); + + if (!this._sessionToken) { + this._sessionToken = new AutocompleteSessionToken(); + } + const includedRegionCodes = this.convertToRegionCodes(componentRestrictions?.country); const { suggestions } = await AutocompleteSuggestion.fetchAutocompleteSuggestions({ input, includedPrimaryTypes: types, includedRegionCodes, + sessionToken: this._sessionToken, }); return suggestions; @@ -34,9 +43,16 @@ export default class GoogleAutocompleteService { async getPlaceDetails(placeId: string, fields: string[]): Promise { const { Place } = await this._scriptLoader.loadPlacesLibrary(this._apiKey); + + // Consume and reset the session token — the session ends when fetchFields is called. + // A new token will be created automatically on the next getSuggestions call. + const sessionToken = this._sessionToken; + + this._sessionToken = undefined; + const place = new Place({ id: placeId }); - await place.fetchFields({ fields }); + await place.fetchFields({ fields, sessionToken }); return place; } From e02bfce8955a18aa3bc2f11d0cdfa985e6bddb8e Mon Sep 17 00:00:00 2001 From: bc-maxy Date: Fri, 19 Jun 2026 17:13:24 +1000 Subject: [PATCH 07/21] fix(checkout): CHECKOUT-10026 Various small cleanup --- .../src/app/address/googleAutocomplete/GoogleAutocomplete.tsx | 2 +- .../googleAutocomplete/GoogleAutocompleteFormField.tsx | 4 ++-- .../googleAutocomplete/getGoogleAutocompleteScriptLoader.ts | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/core/src/app/address/googleAutocomplete/GoogleAutocomplete.tsx b/packages/core/src/app/address/googleAutocomplete/GoogleAutocomplete.tsx index b3cba8855e..863fbd90b5 100644 --- a/packages/core/src/app/address/googleAutocomplete/GoogleAutocomplete.tsx +++ b/packages/core/src/app/address/googleAutocomplete/GoogleAutocomplete.tsx @@ -3,7 +3,7 @@ import React, { useRef, useState } from 'react'; import { Autocomplete, type AutocompleteItem } from '@bigcommerce/checkout/ui'; -import GoogleAutocompleteService from './GoogleAutocompleteService'; +import { GoogleAutocompleteService } from './GoogleAutocompleteService'; import { type GoogleAutocompleteOptionTypes } from './googleAutocompleteTypes'; import { mapToAutocompleteItems, diff --git a/packages/core/src/app/address/googleAutocomplete/GoogleAutocompleteFormField.tsx b/packages/core/src/app/address/googleAutocomplete/GoogleAutocompleteFormField.tsx index 55d2e4b60d..d266ad1eda 100644 --- a/packages/core/src/app/address/googleAutocomplete/GoogleAutocompleteFormField.tsx +++ b/packages/core/src/app/address/googleAutocomplete/GoogleAutocompleteFormField.tsx @@ -50,12 +50,12 @@ const GoogleAutocompleteFormField: FunctionComponent Date: Wed, 24 Jun 2026 10:53:55 +1000 Subject: [PATCH 08/21] fix(checkout): CHECKOUT-10026 Keep the old service alive --- .../googleAutocomplete/GoogleAutocomplete.tsx | 47 +++--- .../GoogleAutocompleteScriptLoader.ts | 65 +++++-- .../GoogleAutocompleteService.test.ts | 140 +--------------- .../GoogleAutocompleteService.ts | 110 +++++++----- .../IGoogleAutocompleteService.ts | 24 +++ .../getGoogleAutocompleteScriptLoader.test.ts | 58 +++---- .../getGoogleAutocompleteScriptLoader.ts | 4 +- .../googleAutocompleteTypes.ts | 39 +++++ .../app/address/googleAutocomplete/index.ts | 7 +- .../GoogleAutocompleteScriptLoader.ts | 26 +++ .../GoogleAutocompleteService.test.ts | 158 ++++++++++++++++++ .../placesNew/GoogleAutocompleteService.ts | 82 +++++++++ .../getGoogleAutocompleteScriptLoader.ts | 11 ++ .../placesNew/utils.test.ts | 88 ++++++++++ .../googleAutocomplete/placesNew/utils.ts | 57 +++++++ .../address/googleAutocomplete/utils.test.ts | 4 +- .../app/address/googleAutocomplete/utils.ts | 64 +------ 17 files changed, 673 insertions(+), 311 deletions(-) create mode 100644 packages/core/src/app/address/googleAutocomplete/IGoogleAutocompleteService.ts create mode 100644 packages/core/src/app/address/googleAutocomplete/placesNew/GoogleAutocompleteScriptLoader.ts create mode 100644 packages/core/src/app/address/googleAutocomplete/placesNew/GoogleAutocompleteService.test.ts create mode 100644 packages/core/src/app/address/googleAutocomplete/placesNew/GoogleAutocompleteService.ts create mode 100644 packages/core/src/app/address/googleAutocomplete/placesNew/getGoogleAutocompleteScriptLoader.ts create mode 100644 packages/core/src/app/address/googleAutocomplete/placesNew/utils.test.ts create mode 100644 packages/core/src/app/address/googleAutocomplete/placesNew/utils.ts diff --git a/packages/core/src/app/address/googleAutocomplete/GoogleAutocomplete.tsx b/packages/core/src/app/address/googleAutocomplete/GoogleAutocomplete.tsx index 863fbd90b5..414873a70b 100644 --- a/packages/core/src/app/address/googleAutocomplete/GoogleAutocomplete.tsx +++ b/packages/core/src/app/address/googleAutocomplete/GoogleAutocomplete.tsx @@ -3,13 +3,9 @@ import React, { useRef, useState } from 'react'; import { Autocomplete, type AutocompleteItem } from '@bigcommerce/checkout/ui'; -import { GoogleAutocompleteService } from './GoogleAutocompleteService'; +import type { IGoogleAutocompleteService } from './IGoogleAutocompleteService'; +import { GoogleAutocompleteService as PlacesNewService } from './placesNew/GoogleAutocompleteService'; import { type GoogleAutocompleteOptionTypes } from './googleAutocompleteTypes'; -import { - mapToAutocompleteItems, - mapToGeocoderAddressComponent, - mapToIncludedPrimaryTypes, -} from './utils'; import './GoogleAutocomplete.scss'; export interface GoogleAutocompleteProps { @@ -21,6 +17,11 @@ export interface GoogleAutocompleteProps { inputProps?: any; isAutocompleteEnabled?: boolean; types?: GoogleAutocompleteOptionTypes[]; + /** + * Override the autocomplete backend. Defaults to the Places API (New) implementation. + * Pass `new GoogleAutocompleteService(apiKey)` (legacy) to use the old Places API. + */ + service?: IGoogleAutocompleteService; onSelect?(place: google.maps.places.PlaceResult, item: AutocompleteItem): void; onToggleOpen?(state: { inputValue: string; isOpen: boolean }): void; onChange?(value: string, isOpen: boolean): void; @@ -38,28 +39,23 @@ export const GoogleAutocomplete: React.FC = ({ componentRestrictions, types, apiKey, + service, }) => { const [items, setItems] = useState([]); const [autoComplete, setAutoComplete] = useState('off'); - const googleAutocompleteServiceRef = useRef(); + const serviceRef = useRef(); - if (!googleAutocompleteServiceRef.current) { - googleAutocompleteServiceRef.current = new GoogleAutocompleteService(apiKey); + if (!serviceRef.current) { + serviceRef.current = service ?? new PlacesNewService(apiKey); } const handleSelect = (item: AutocompleteItem) => { - const service = googleAutocompleteServiceRef.current; + const svc = serviceRef.current; - if (!service) return; - - service - .getPlaceDetails(item.id, fields || ['addressComponents', 'displayName']) - .then((place) => { - const placeResult: google.maps.places.PlaceResult = { - address_components: place.addressComponents?.map(mapToGeocoderAddressComponent), - name: place.displayName ?? '', - }; + if (!svc) return; + svc.getPlaceDetails(item.id, fields) + .then((placeResult) => { if (nextElement) { nextElement.focus(); } @@ -89,17 +85,12 @@ export const GoogleAutocomplete: React.FC = ({ return; } - const service = googleAutocompleteServiceRef.current; + const svc = serviceRef.current; - if (!service) return; + if (!svc) return; - service - .getSuggestions( - input, - mapToIncludedPrimaryTypes(types || ['geocode']), - componentRestrictions, - ) - .then((suggestions) => setItems(mapToAutocompleteItems(suggestions))) + svc.getSuggestions(input, types, componentRestrictions) + .then((suggestions) => setItems(suggestions)) .catch(() => setItems([])); }; diff --git a/packages/core/src/app/address/googleAutocomplete/GoogleAutocompleteScriptLoader.ts b/packages/core/src/app/address/googleAutocomplete/GoogleAutocompleteScriptLoader.ts index 25a720848c..c74df6d854 100644 --- a/packages/core/src/app/address/googleAutocomplete/GoogleAutocompleteScriptLoader.ts +++ b/packages/core/src/app/address/googleAutocomplete/GoogleAutocompleteScriptLoader.ts @@ -1,26 +1,59 @@ import { getScriptLoader, type ScriptLoader } from '@bigcommerce/script-loader'; -export class GoogleAutocompleteScriptLoader { - private _scriptLoader: ScriptLoader = getScriptLoader(); - private _placesPromise?: Promise; +import { type GoogleAutocompleteWindow, type GoogleMapsSdk } from './googleAutocompleteTypes'; - loadPlacesLibrary(apiKey: string): Promise { - if (this._placesPromise) { - return this._placesPromise; +export default class GoogleAutocompleteScriptLoader { + private _scriptLoader: ScriptLoader; + private _googleAutoComplete?: Promise; + + constructor() { + this._scriptLoader = getScriptLoader(); + } + + loadMapsSdk(apiKey: string): Promise { + if (this._googleAutoComplete) { + return this._googleAutoComplete; } - const params = ['language=en', `key=${apiKey}`, 'loading=async'].join('&'); + this._googleAutoComplete = new Promise((resolve, reject) => { + const callbackName = 'initAutoComplete'; + const params = [ + 'language=en', + `key=${apiKey}`, + 'libraries=places', + `callback=${callbackName}`, + ].join('&'); - const promise = this._scriptLoader - .loadScript(`//maps.googleapis.com/maps/api/js?${params}`) - .then(() => google.maps.importLibrary('places') as Promise) - .catch((e) => { - this._placesPromise = undefined; - throw e; - }); + (window as GoogleCallbackWindow)[callbackName] = () => { + if (isAutocompleteWindow(window)) { + resolve(window.google.maps); + } - this._placesPromise = promise; + reject(new Error('Failed to initialize Google Maps Autocomplete SDK.')); + }; - return promise; + this._scriptLoader + .loadScript(`//maps.googleapis.com/maps/api/js?${params}`) + .catch((e) => { + this._googleAutoComplete = undefined; + throw e; + }); + }); + + return this._googleAutoComplete; } } + +function isAutocompleteWindow(window: Window): window is GoogleAutocompleteWindow { + const autocompleteWindow = window as GoogleAutocompleteWindow; + + return Boolean( + autocompleteWindow.google && + autocompleteWindow.google.maps && + autocompleteWindow.google.maps.places, + ); +} + +export interface GoogleCallbackWindow extends Window { + initAutoComplete?(): void; +} diff --git a/packages/core/src/app/address/googleAutocomplete/GoogleAutocompleteService.test.ts b/packages/core/src/app/address/googleAutocomplete/GoogleAutocompleteService.test.ts index 74a59d59ac..ec4b6c9a15 100644 --- a/packages/core/src/app/address/googleAutocomplete/GoogleAutocompleteService.test.ts +++ b/packages/core/src/app/address/googleAutocomplete/GoogleAutocompleteService.test.ts @@ -1,137 +1,5 @@ -import { type GoogleAutocompleteScriptLoader } from './GoogleAutocompleteScriptLoader'; -import { GoogleAutocompleteService } from './GoogleAutocompleteService'; - -const mockSuggestions = [ - { placePrediction: { placeId: 'place-1' } }, -] as google.maps.places.AutocompleteSuggestion[]; - -const mockPlace = { - fetchFields: jest.fn().mockResolvedValue(undefined), - addressComponents: [], - displayName: '123 Main St', -} as google.maps.places.Place; - -const mockSessionToken = {} as google.maps.places.AutocompleteSessionToken; - -const mockPlacesLibrary = { - AutocompleteSuggestion: { - fetchAutocompleteSuggestions: jest.fn().mockResolvedValue({ suggestions: mockSuggestions }), - }, - AutocompleteSessionToken: jest.fn().mockImplementation(() => mockSessionToken), - Place: jest.fn().mockImplementation(() => mockPlace), -} as google.maps.PlacesLibrary; - -const mockScriptLoader = { - loadPlacesLibrary: jest.fn().mockResolvedValue(mockPlacesLibrary), -} as unknown as GoogleAutocompleteScriptLoader; - -describe('GoogleAutocompleteService', () => { - let service: GoogleAutocompleteService; - - beforeEach(() => { - jest.clearAllMocks(); - service = new GoogleAutocompleteService('test-api-key', mockScriptLoader); - }); - - describe('#getSuggestions()', () => { - it('returns suggestions from the API', async () => { - const result = await service.getSuggestions('123 Main', ['address']); - - expect(result).toBe(mockSuggestions); - }); - - it('passes input and types to fetchAutocompleteSuggestions', async () => { - await service.getSuggestions('123 Main', ['address']); - - expect( - mockPlacesLibrary.AutocompleteSuggestion.fetchAutocompleteSuggestions, - ).toHaveBeenCalledWith( - expect.objectContaining({ input: '123 Main', includedPrimaryTypes: ['address'] }), - ); - }); - - it('maps a string country restriction to includedRegionCodes array', async () => { - await service.getSuggestions('123 Main', ['address'], { country: 'US' }); - - expect( - mockPlacesLibrary.AutocompleteSuggestion.fetchAutocompleteSuggestions, - ).toHaveBeenCalledWith(expect.objectContaining({ includedRegionCodes: ['US'] })); - }); - - it('maps an array country restriction to includedRegionCodes', async () => { - await service.getSuggestions('123 Main', ['address'], { country: ['US', 'CA'] }); - - expect( - mockPlacesLibrary.AutocompleteSuggestion.fetchAutocompleteSuggestions, - ).toHaveBeenCalledWith(expect.objectContaining({ includedRegionCodes: ['US', 'CA'] })); - }); - - it('omits includedRegionCodes when no componentRestrictions provided', async () => { - await service.getSuggestions('123 Main', ['address']); - - expect( - mockPlacesLibrary.AutocompleteSuggestion.fetchAutocompleteSuggestions, - ).toHaveBeenCalledWith(expect.objectContaining({ includedRegionCodes: undefined })); - }); - - it('creates a session token on the first call and passes it to the API', async () => { - await service.getSuggestions('123 Main', ['address']); - - expect(mockPlacesLibrary.AutocompleteSessionToken).toHaveBeenCalledTimes(1); - expect( - mockPlacesLibrary.AutocompleteSuggestion.fetchAutocompleteSuggestions, - ).toHaveBeenCalledWith(expect.objectContaining({ sessionToken: mockSessionToken })); - }); - - it('reuses the same session token across multiple calls', async () => { - await service.getSuggestions('1', ['address']); - await service.getSuggestions('12', ['address']); - await service.getSuggestions('123', ['address']); - - expect(mockPlacesLibrary.AutocompleteSessionToken).toHaveBeenCalledTimes(1); - }); - }); - - describe('#getPlaceDetails()', () => { - it('constructs a Place with the given placeId', async () => { - await service.getPlaceDetails('place-1', ['addressComponents', 'displayName']); - - expect(mockPlacesLibrary.Place).toHaveBeenCalledWith({ id: 'place-1' }); - }); - - it('calls fetchFields with the given fields and the active session token', async () => { - await service.getSuggestions('123 Main', ['address']); - await service.getPlaceDetails('place-1', ['addressComponents', 'displayName']); - - expect(mockPlace.fetchFields).toHaveBeenCalledWith({ - fields: ['addressComponents', 'displayName'], - sessionToken: mockSessionToken, - }); - }); - - it('calls fetchFields with no session token if getSuggestions was never called', async () => { - await service.getPlaceDetails('place-1', ['addressComponents', 'displayName']); - - expect(mockPlace.fetchFields).toHaveBeenCalledWith({ - fields: ['addressComponents', 'displayName'], - sessionToken: undefined, - }); - }); - - it('resets the session token after getPlaceDetails so the next session gets a fresh token', async () => { - await service.getSuggestions('123 Main', ['address']); - await service.getPlaceDetails('place-1', ['addressComponents']); - - // Start a new typing session - await service.getSuggestions('456 Oak', ['address']); - - expect(mockPlacesLibrary.AutocompleteSessionToken).toHaveBeenCalledTimes(2); - }); - - it('returns the place after fetching fields', async () => { - const result = await service.getPlaceDetails('place-1', ['addressComponents']); - - expect(result).toBe(mockPlace); - }); - }); +// Tests for the Places API (New) service have moved to placesNew/GoogleAutocompleteService.test.ts. +// This file is kept as a shell because it cannot be deleted in this environment. +describe('GoogleAutocompleteService (legacy)', () => { + it.todo('add tests for the legacy Places API service'); }); diff --git a/packages/core/src/app/address/googleAutocomplete/GoogleAutocompleteService.ts b/packages/core/src/app/address/googleAutocomplete/GoogleAutocompleteService.ts index d6a3fcc82c..cb6d86bbe3 100644 --- a/packages/core/src/app/address/googleAutocomplete/GoogleAutocompleteService.ts +++ b/packages/core/src/app/address/googleAutocomplete/GoogleAutocompleteService.ts @@ -1,59 +1,93 @@ -import { getGoogleAutocompleteScriptLoader } from './getGoogleAutocompleteScriptLoader'; -import { type GoogleAutocompleteScriptLoader } from './GoogleAutocompleteScriptLoader'; +import type { AutocompleteItem } from '@bigcommerce/checkout/ui'; -export class GoogleAutocompleteService { - private _sessionToken?: google.maps.places.AutocompleteSessionToken; +import getGoogleAutocompleteScriptLoader from './getGoogleAutocompleteScriptLoader'; +import type GoogleAutocompleteScriptLoader from './GoogleAutocompleteScriptLoader'; +import type { IGoogleAutocompleteService } from './IGoogleAutocompleteService'; + +const toAutocompleteItems = ( + results?: google.maps.places.AutocompletePrediction[], +): AutocompleteItem[] => + (results || []).map((result) => ({ + label: result.description, + value: result.structured_formatting.main_text, + highlightedSlices: result.matched_substrings, + id: result.place_id, + })); + +export default class GoogleAutocompleteService implements IGoogleAutocompleteService { + private _autocompletePromise?: Promise; + private _placesPromise?: Promise; constructor( private _apiKey: string, private _scriptLoader: GoogleAutocompleteScriptLoader = getGoogleAutocompleteScriptLoader(), ) {} - private convertToRegionCodes( - country: string | string[] | null | undefined, - ): string[] | undefined { - if (!country) return undefined; - - return Array.isArray(country) ? country : [country]; - } - - async getSuggestions( + getSuggestions( input: string, types: string[] | undefined, componentRestrictions?: google.maps.places.ComponentRestrictions, - ): Promise { - const { AutocompleteSuggestion, AutocompleteSessionToken } = - await this._scriptLoader.loadPlacesLibrary(this._apiKey); + ): Promise { + return this.getAutocompleteService().then( + (autocompleteService) => + new Promise((resolve) => { + autocompleteService.getPlacePredictions( + { input, types: types || ['geocode'], componentRestrictions }, + (results) => resolve(toAutocompleteItems(results ?? undefined)), + ); + }), + ); + } - if (!this._sessionToken) { - this._sessionToken = new AutocompleteSessionToken(); - } + getPlaceDetails( + placeId: string, + fields?: string[], + ): Promise { + return this.getPlacesServices().then( + (placesService) => + new Promise((resolve) => { + placesService.getDetails( + { + placeId, + fields: fields?.length ? fields : ['address_components', 'name'], + }, + (result) => resolve(result ?? {}), + ); + }), + ); + } - const includedRegionCodes = this.convertToRegionCodes(componentRestrictions?.country); + getAutocompleteService(): Promise { + if (!this._autocompletePromise) { + this._autocompletePromise = this._scriptLoader + .loadMapsSdk(this._apiKey) + .then((googleMapsSdk) => { + if (!googleMapsSdk.places.AutocompleteService) { + throw new Error('`AutocompleteService` is undefined'); + } - const { suggestions } = await AutocompleteSuggestion.fetchAutocompleteSuggestions({ - input, - includedPrimaryTypes: types, - includedRegionCodes, - sessionToken: this._sessionToken, - }); + return new googleMapsSdk.places.AutocompleteService(); + }); + } - return suggestions; + return this._autocompletePromise; } - async getPlaceDetails(placeId: string, fields: string[]): Promise { - const { Place } = await this._scriptLoader.loadPlacesLibrary(this._apiKey); + getPlacesServices(): Promise { + const node = document.createElement('div'); - // Consume and reset the session token — the session ends when fetchFields is called. - // A new token will be created automatically on the next getSuggestions call. - const sessionToken = this._sessionToken; + if (!this._placesPromise) { + this._placesPromise = this._scriptLoader + .loadMapsSdk(this._apiKey) + .then((googleMapsSdk) => { + if (!googleMapsSdk.places.PlacesService) { + throw new Error('`PlacesService` is undefined'); + } - this._sessionToken = undefined; - - const place = new Place({ id: placeId }); - - await place.fetchFields({ fields, sessionToken }); + return new googleMapsSdk.places.PlacesService(node); + }); + } - return place; + return this._placesPromise; } } diff --git a/packages/core/src/app/address/googleAutocomplete/IGoogleAutocompleteService.ts b/packages/core/src/app/address/googleAutocomplete/IGoogleAutocompleteService.ts new file mode 100644 index 0000000000..7d3af871e8 --- /dev/null +++ b/packages/core/src/app/address/googleAutocomplete/IGoogleAutocompleteService.ts @@ -0,0 +1,24 @@ +import type { AutocompleteItem } from '@bigcommerce/checkout/ui'; + +/** + * Common interface for Google Autocomplete service implementations. + * + * Two implementations exist: + * - `GoogleAutocompleteService` (root) — legacy Places API (callback-based) + * - `placesNew/GoogleAutocompleteService` — Places API (New), promise-based + * + * Pass an instance via the `service` prop on `GoogleAutocomplete` to select + * which backend to use. + */ +export interface IGoogleAutocompleteService { + getSuggestions( + input: string, + types: string[] | undefined, + componentRestrictions?: google.maps.places.ComponentRestrictions, + ): Promise; + + getPlaceDetails( + placeId: string, + fields?: string[], + ): Promise; +} diff --git a/packages/core/src/app/address/googleAutocomplete/getGoogleAutocompleteScriptLoader.test.ts b/packages/core/src/app/address/googleAutocomplete/getGoogleAutocompleteScriptLoader.test.ts index 63c62f345a..737984d0d6 100644 --- a/packages/core/src/app/address/googleAutocomplete/getGoogleAutocompleteScriptLoader.test.ts +++ b/packages/core/src/app/address/googleAutocomplete/getGoogleAutocompleteScriptLoader.test.ts @@ -1,60 +1,54 @@ import { getScriptLoader, type ScriptLoader } from '@bigcommerce/script-loader'; -import { GoogleAutocompleteScriptLoader } from './GoogleAutocompleteScriptLoader'; - -interface WindowWithGoogleMaps extends Window { - google?: { - maps: { - importLibrary: (libraryName: string) => Promise; - }; - }; -} - -const mockPlacesLibrary = {} as google.maps.PlacesLibrary; +import GoogleAutocompleteScriptLoader, { + type GoogleCallbackWindow, +} from './GoogleAutocompleteScriptLoader'; +import { type GoogleAutocompleteWindow } from './googleAutocompleteTypes'; describe('GoogleAutocompleteScriptLoader', () => { const scriptLoader: ScriptLoader = getScriptLoader(); let googleScriptLoader: GoogleAutocompleteScriptLoader; - describe('#loadPlacesLibrary()', () => { - let spiedLoadScript: jest.SpyInstance; + describe('#loadMapsSdk()', () => { + let spiedLoadScript: any; beforeEach(async () => { spiedLoadScript = jest.spyOn(scriptLoader, 'loadScript'); - spiedLoadScript.mockResolvedValue(undefined); + spiedLoadScript.mockImplementation(() => { + (window as GoogleAutocompleteWindow).google = { + maps: { + places: {}, + } as any, + }; - (window as WindowWithGoogleMaps).google = { - maps: { - importLibrary: jest.fn().mockResolvedValue(mockPlacesLibrary), - }, - }; + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + (window as GoogleCallbackWindow).initAutoComplete!(); + }); googleScriptLoader = new GoogleAutocompleteScriptLoader(); - await googleScriptLoader.loadPlacesLibrary('foo'); + await googleScriptLoader.loadMapsSdk('foo'); }); afterEach(() => { spiedLoadScript.mockReset(); - delete (window as WindowWithGoogleMaps).google; }); it('calls loadScript with the right parameters', () => { expect(scriptLoader.loadScript).toHaveBeenCalledWith( - '//maps.googleapis.com/maps/api/js?language=en&key=foo&loading=async', + [ + '//maps.googleapis.com/maps/api/js?language=en', + 'key=foo', + 'libraries=places', + 'callback=initAutoComplete', + ].join('&'), ); }); - it('resolves with the places library', async () => { - const result = await googleScriptLoader.loadPlacesLibrary('foo'); - - expect(result).toBe(mockPlacesLibrary); - }); - - it('calls loadScript once regardless of how many times loadPlacesLibrary is called', async () => { - await googleScriptLoader.loadPlacesLibrary('x'); - await googleScriptLoader.loadPlacesLibrary('y'); - await googleScriptLoader.loadPlacesLibrary('z'); + it('calls loadScript once', async () => { + await googleScriptLoader.loadMapsSdk('x'); + await googleScriptLoader.loadMapsSdk('y'); + await googleScriptLoader.loadMapsSdk('z'); expect(scriptLoader.loadScript).toHaveBeenCalledTimes(1); }); diff --git a/packages/core/src/app/address/googleAutocomplete/getGoogleAutocompleteScriptLoader.ts b/packages/core/src/app/address/googleAutocomplete/getGoogleAutocompleteScriptLoader.ts index 6151f48dfe..f14f2d5ed6 100644 --- a/packages/core/src/app/address/googleAutocomplete/getGoogleAutocompleteScriptLoader.ts +++ b/packages/core/src/app/address/googleAutocomplete/getGoogleAutocompleteScriptLoader.ts @@ -1,8 +1,8 @@ -import { GoogleAutocompleteScriptLoader } from './GoogleAutocompleteScriptLoader'; +import GoogleAutocompleteScriptLoader from './GoogleAutocompleteScriptLoader'; let instance: GoogleAutocompleteScriptLoader; -export function getGoogleAutocompleteScriptLoader(): GoogleAutocompleteScriptLoader { +export default function getGoogleAutocompleteScriptLoader(): GoogleAutocompleteScriptLoader { if (!instance) { instance = new GoogleAutocompleteScriptLoader(); } diff --git a/packages/core/src/app/address/googleAutocomplete/googleAutocompleteTypes.ts b/packages/core/src/app/address/googleAutocomplete/googleAutocompleteTypes.ts index 9a7b2f27d2..d78c8f72d3 100644 --- a/packages/core/src/app/address/googleAutocomplete/googleAutocompleteTypes.ts +++ b/packages/core/src/app/address/googleAutocomplete/googleAutocompleteTypes.ts @@ -1,5 +1,44 @@ export type GoogleAutocompleteOptionTypes = 'establishment' | 'geocode' | 'address'; +export type GoogleAutocompleteFields = + | 'address_components' + | 'adr_address' + | 'aspects' + | 'formatted_address' + | 'formatted_phone_number' + | 'geometry' + | 'html_attributions' + | 'icon' + | 'international_phone_number' + | 'name' + | 'opening_hours' + | 'photos' + | 'place_id' + | 'plus_code' + | 'price_level' + | 'rating' + | 'reviews' + | 'types' + | 'url' + | 'utc_offset' + | 'vicinity' + | 'website'; + +export type GoogleAutocompleteEvent = 'place_changed'; + +export interface GoogleMapsSdk { + places: { + AutocompleteService?: new () => google.maps.places.AutocompleteService; + PlacesService?: new (attrContainer: HTMLDivElement) => google.maps.places.PlacesService; + }; +} + +export interface GoogleAutocompleteWindow extends Window { + google: { + maps: GoogleMapsSdk; + }; +} + export type GoogleAddressFieldType = | 'postal_town' | 'administrative_area_level_1' diff --git a/packages/core/src/app/address/googleAutocomplete/index.ts b/packages/core/src/app/address/googleAutocomplete/index.ts index cf5eaebea1..de4f172d08 100644 --- a/packages/core/src/app/address/googleAutocomplete/index.ts +++ b/packages/core/src/app/address/googleAutocomplete/index.ts @@ -1,5 +1,10 @@ export * from './googleAutocompleteTypes'; export { default as mapToAddress } from './mapToAddress'; -export { GoogleAutocompleteScriptLoader } from './GoogleAutocompleteScriptLoader'; +export { default as GoogleAutocompleteScriptLoader } from './GoogleAutocompleteScriptLoader'; export { GoogleAutocomplete } from './GoogleAutocomplete'; export { default as GoogleAutocompleteFormField } from './GoogleAutocompleteFormField'; +export type { IGoogleAutocompleteService } from './IGoogleAutocompleteService'; +export { GoogleAutocompleteService as PlacesNewGoogleAutocompleteService } from './placesNew/GoogleAutocompleteService'; +export { + GoogleAutocompleteScriptLoader as PlacesNewGoogleAutocompleteScriptLoader, +} from './placesNew/GoogleAutocompleteScriptLoader'; diff --git a/packages/core/src/app/address/googleAutocomplete/placesNew/GoogleAutocompleteScriptLoader.ts b/packages/core/src/app/address/googleAutocomplete/placesNew/GoogleAutocompleteScriptLoader.ts new file mode 100644 index 0000000000..25a720848c --- /dev/null +++ b/packages/core/src/app/address/googleAutocomplete/placesNew/GoogleAutocompleteScriptLoader.ts @@ -0,0 +1,26 @@ +import { getScriptLoader, type ScriptLoader } from '@bigcommerce/script-loader'; + +export class GoogleAutocompleteScriptLoader { + private _scriptLoader: ScriptLoader = getScriptLoader(); + private _placesPromise?: Promise; + + loadPlacesLibrary(apiKey: string): Promise { + if (this._placesPromise) { + return this._placesPromise; + } + + const params = ['language=en', `key=${apiKey}`, 'loading=async'].join('&'); + + const promise = this._scriptLoader + .loadScript(`//maps.googleapis.com/maps/api/js?${params}`) + .then(() => google.maps.importLibrary('places') as Promise) + .catch((e) => { + this._placesPromise = undefined; + throw e; + }); + + this._placesPromise = promise; + + return promise; + } +} diff --git a/packages/core/src/app/address/googleAutocomplete/placesNew/GoogleAutocompleteService.test.ts b/packages/core/src/app/address/googleAutocomplete/placesNew/GoogleAutocompleteService.test.ts new file mode 100644 index 0000000000..d7ec509cf7 --- /dev/null +++ b/packages/core/src/app/address/googleAutocomplete/placesNew/GoogleAutocompleteService.test.ts @@ -0,0 +1,158 @@ +import { type GoogleAutocompleteScriptLoader } from './GoogleAutocompleteScriptLoader'; +import { GoogleAutocompleteService } from './GoogleAutocompleteService'; + +const mockSuggestions = [ + { + placePrediction: { + placeId: 'place-1', + text: { text: '123 Main St, NY', matches: [] }, + mainText: { text: '123 Main St' }, + }, + }, +] as unknown as google.maps.places.AutocompleteSuggestion[]; + +const mockPlace = { + fetchFields: jest.fn().mockResolvedValue(undefined), + addressComponents: [], + displayName: '123 Main St', +} as unknown as google.maps.places.Place; + +const mockSessionToken = {} as google.maps.places.AutocompleteSessionToken; + +const mockPlacesLibrary = { + AutocompleteSuggestion: { + fetchAutocompleteSuggestions: jest.fn().mockResolvedValue({ suggestions: mockSuggestions }), + }, + AutocompleteSessionToken: jest.fn().mockImplementation(() => mockSessionToken), + Place: jest.fn().mockImplementation(() => mockPlace), +} as unknown as google.maps.PlacesLibrary; + +const mockScriptLoader = { + loadPlacesLibrary: jest.fn().mockResolvedValue(mockPlacesLibrary), +} as unknown as GoogleAutocompleteScriptLoader; + +describe('GoogleAutocompleteService', () => { + let service: GoogleAutocompleteService; + + beforeEach(() => { + jest.clearAllMocks(); + service = new GoogleAutocompleteService('test-api-key', mockScriptLoader); + }); + + describe('#getSuggestions()', () => { + it('returns suggestions mapped to AutocompleteItems', async () => { + const result = await service.getSuggestions('123 Main', ['address']); + + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ id: 'place-1', label: '123 Main St, NY' }); + }); + + it('passes input and mapped types to fetchAutocompleteSuggestions', async () => { + await service.getSuggestions('123 Main', ['address']); + + expect( + mockPlacesLibrary.AutocompleteSuggestion.fetchAutocompleteSuggestions, + ).toHaveBeenCalledWith( + expect.objectContaining({ + input: '123 Main', + includedPrimaryTypes: ['street_address'], + }), + ); + }); + + it('maps a string country restriction to includedRegionCodes array', async () => { + await service.getSuggestions('123 Main', ['address'], { country: 'US' }); + + expect( + mockPlacesLibrary.AutocompleteSuggestion.fetchAutocompleteSuggestions, + ).toHaveBeenCalledWith(expect.objectContaining({ includedRegionCodes: ['US'] })); + }); + + it('maps an array country restriction to includedRegionCodes', async () => { + await service.getSuggestions('123 Main', ['address'], { country: ['US', 'CA'] }); + + expect( + mockPlacesLibrary.AutocompleteSuggestion.fetchAutocompleteSuggestions, + ).toHaveBeenCalledWith(expect.objectContaining({ includedRegionCodes: ['US', 'CA'] })); + }); + + it('omits includedRegionCodes when no componentRestrictions provided', async () => { + await service.getSuggestions('123 Main', ['address']); + + expect( + mockPlacesLibrary.AutocompleteSuggestion.fetchAutocompleteSuggestions, + ).toHaveBeenCalledWith(expect.objectContaining({ includedRegionCodes: undefined })); + }); + + it('creates a session token on the first call and passes it to the API', async () => { + await service.getSuggestions('123 Main', ['address']); + + expect(mockPlacesLibrary.AutocompleteSessionToken).toHaveBeenCalledTimes(1); + expect( + mockPlacesLibrary.AutocompleteSuggestion.fetchAutocompleteSuggestions, + ).toHaveBeenCalledWith(expect.objectContaining({ sessionToken: mockSessionToken })); + }); + + it('reuses the same session token across multiple calls', async () => { + await service.getSuggestions('1', ['address']); + await service.getSuggestions('12', ['address']); + await service.getSuggestions('123', ['address']); + + expect(mockPlacesLibrary.AutocompleteSessionToken).toHaveBeenCalledTimes(1); + }); + }); + + describe('#getPlaceDetails()', () => { + it('constructs a Place with the given placeId', async () => { + await service.getPlaceDetails('place-1', ['addressComponents', 'displayName']); + + expect(mockPlacesLibrary.Place).toHaveBeenCalledWith({ id: 'place-1' }); + }); + + it('calls fetchFields with the given fields and the active session token', async () => { + await service.getSuggestions('123 Main', ['address']); + await service.getPlaceDetails('place-1', ['addressComponents', 'displayName']); + + expect(mockPlace.fetchFields).toHaveBeenCalledWith({ + fields: ['addressComponents', 'displayName'], + sessionToken: mockSessionToken, + }); + }); + + it('calls fetchFields with no session token if getSuggestions was never called', async () => { + await service.getPlaceDetails('place-1', ['addressComponents', 'displayName']); + + expect(mockPlace.fetchFields).toHaveBeenCalledWith({ + fields: ['addressComponents', 'displayName'], + sessionToken: undefined, + }); + }); + + it('resets the session token after getPlaceDetails so the next session gets a fresh token', async () => { + await service.getSuggestions('123 Main', ['address']); + await service.getPlaceDetails('place-1', ['addressComponents']); + + // Start a new typing session + await service.getSuggestions('456 Oak', ['address']); + + expect(mockPlacesLibrary.AutocompleteSessionToken).toHaveBeenCalledTimes(2); + }); + + it('returns place details mapped to PlaceResult', async () => { + const result = await service.getPlaceDetails('place-1', ['addressComponents', 'displayName']); + + expect(result).toEqual({ + address_components: [], + name: '123 Main St', + }); + }); + + it('uses default fields when none provided', async () => { + await service.getPlaceDetails('place-1'); + + expect(mockPlace.fetchFields).toHaveBeenCalledWith( + expect.objectContaining({ fields: ['addressComponents', 'displayName'] }), + ); + }); + }); +}); diff --git a/packages/core/src/app/address/googleAutocomplete/placesNew/GoogleAutocompleteService.ts b/packages/core/src/app/address/googleAutocomplete/placesNew/GoogleAutocompleteService.ts new file mode 100644 index 0000000000..4d50d1226f --- /dev/null +++ b/packages/core/src/app/address/googleAutocomplete/placesNew/GoogleAutocompleteService.ts @@ -0,0 +1,82 @@ +import type { AutocompleteItem } from '@bigcommerce/checkout/ui'; + +import type { IGoogleAutocompleteService } from '../IGoogleAutocompleteService'; +import type { GoogleAutocompleteOptionTypes } from '../googleAutocompleteTypes'; +import { getGoogleAutocompleteScriptLoader } from './getGoogleAutocompleteScriptLoader'; +import type { GoogleAutocompleteScriptLoader } from './GoogleAutocompleteScriptLoader'; +import { + mapToAutocompleteItems, + mapToGeocoderAddressComponent, + mapToIncludedPrimaryTypes, +} from './utils'; + +export class GoogleAutocompleteService implements IGoogleAutocompleteService { + private _sessionToken?: google.maps.places.AutocompleteSessionToken; + + constructor( + private _apiKey: string, + private _scriptLoader: GoogleAutocompleteScriptLoader = getGoogleAutocompleteScriptLoader(), + ) {} + + async getSuggestions( + input: string, + types: string[] | undefined, + componentRestrictions?: google.maps.places.ComponentRestrictions, + ): Promise { + const { AutocompleteSuggestion, AutocompleteSessionToken } = + await this._scriptLoader.loadPlacesLibrary(this._apiKey); + + if (!this._sessionToken) { + this._sessionToken = new AutocompleteSessionToken(); + } + + const includedRegionCodes = this.convertToRegionCodes(componentRestrictions?.country); + const includedPrimaryTypes = mapToIncludedPrimaryTypes( + (types ?? ['geocode']) as GoogleAutocompleteOptionTypes[], + ); + + const { suggestions } = await AutocompleteSuggestion.fetchAutocompleteSuggestions({ + input, + includedPrimaryTypes, + includedRegionCodes, + sessionToken: this._sessionToken, + }); + + return mapToAutocompleteItems(suggestions); + } + + async getPlaceDetails( + placeId: string, + fields?: string[], + ): Promise { + const { Place } = await this._scriptLoader.loadPlacesLibrary(this._apiKey); + + // Consume and reset the session token — the session ends when fetchFields is called. + // A new token will be created automatically on the next getSuggestions call. + const sessionToken = this._sessionToken; + + this._sessionToken = undefined; + + const place = new Place({ id: placeId }); + + // sessionToken is a valid runtime property but absent from the type definition + await place.fetchFields({ + fields: fields?.length ? fields : ['addressComponents', 'displayName'], + // eslint-disable-next-line @typescript-eslint/no-explicit-any + sessionToken: sessionToken as any, + } as any); + + return { + address_components: place.addressComponents?.map(mapToGeocoderAddressComponent), + name: place.displayName ?? '', + }; + } + + private convertToRegionCodes( + country: string | string[] | null | undefined, + ): string[] | undefined { + if (!country) return undefined; + + return Array.isArray(country) ? country : [country]; + } +} diff --git a/packages/core/src/app/address/googleAutocomplete/placesNew/getGoogleAutocompleteScriptLoader.ts b/packages/core/src/app/address/googleAutocomplete/placesNew/getGoogleAutocompleteScriptLoader.ts new file mode 100644 index 0000000000..6151f48dfe --- /dev/null +++ b/packages/core/src/app/address/googleAutocomplete/placesNew/getGoogleAutocompleteScriptLoader.ts @@ -0,0 +1,11 @@ +import { GoogleAutocompleteScriptLoader } from './GoogleAutocompleteScriptLoader'; + +let instance: GoogleAutocompleteScriptLoader; + +export function getGoogleAutocompleteScriptLoader(): GoogleAutocompleteScriptLoader { + if (!instance) { + instance = new GoogleAutocompleteScriptLoader(); + } + + return instance; +} diff --git a/packages/core/src/app/address/googleAutocomplete/placesNew/utils.test.ts b/packages/core/src/app/address/googleAutocomplete/placesNew/utils.test.ts new file mode 100644 index 0000000000..e9bf9fcc5d --- /dev/null +++ b/packages/core/src/app/address/googleAutocomplete/placesNew/utils.test.ts @@ -0,0 +1,88 @@ +import { + mapToAutocompleteItems, + mapToGeocoderAddressComponent, + mapToIncludedPrimaryTypes, +} from './utils'; + +describe('mapToIncludedPrimaryTypes', () => { + it('maps "address" to "street_address"', () => { + expect(mapToIncludedPrimaryTypes(['address'])).toEqual(['street_address']); + }); + + it('keeps "establishment" as-is', () => { + expect(mapToIncludedPrimaryTypes(['establishment'])).toEqual(['establishment']); + }); + + it('returns undefined for "geocode" (no direct equivalent)', () => { + expect(mapToIncludedPrimaryTypes(['geocode'])).toBeUndefined(); + }); + + it('filters out null-mapped types and returns the rest', () => { + expect(mapToIncludedPrimaryTypes(['geocode', 'address'])).toEqual(['street_address']); + }); + + it('returns undefined for an empty array', () => { + expect(mapToIncludedPrimaryTypes([])).toBeUndefined(); + }); +}); + +describe('mapToAutocompleteItems', () => { + it('maps a suggestion to an autocomplete item', () => { + const mockSuggestions: google.maps.places.AutocompleteSuggestion[] = [ + { + placePrediction: { + placeId: 'place-1', + text: { + text: '123 Main St, New York', + matches: [{ startOffset: 0, endOffset: 3 }], + }, + mainText: { text: '123 Main St' }, + } as google.maps.places.PlacePrediction, + }, + ]; + + const items = mapToAutocompleteItems(mockSuggestions); + + expect(items).toHaveLength(1); + expect(items[0]).toMatchObject({ + id: 'place-1', + label: '123 Main St, New York', + value: '123 Main St', + highlightedSlices: [{ offset: 0, length: 3 }], + }); + }); + + it('skips suggestions without placePrediction', () => { + const mockSuggestionsWithoutPlacePrediction: google.maps.places.AutocompleteSuggestion[] = [ + { placePrediction: null }, + ]; + + expect(mapToAutocompleteItems(mockSuggestionsWithoutPlacePrediction)).toEqual([]); + }); +}); + +describe('mapToGeocoderAddressComponent', () => { + it('maps an AddressComponent to a GeocoderAddressComponent', () => { + const addressComponentMock = { + longText: 'New York', + shortText: 'NY', + types: ['locality', 'political'], + } as google.maps.places.AddressComponent; + + expect(mapToGeocoderAddressComponent(addressComponentMock)).toEqual({ + long_name: 'New York', + short_name: 'NY', + types: ['locality', 'political'], + }); + }); + + it('falls back to empty strings and empty array when fields are missing', () => { + const component = {} as google.maps.places.AddressComponent; + + expect(mapToGeocoderAddressComponent(component)).toEqual({ + long_name: '', + short_name: '', + types: [], + }); + }); +}); diff --git a/packages/core/src/app/address/googleAutocomplete/placesNew/utils.ts b/packages/core/src/app/address/googleAutocomplete/placesNew/utils.ts new file mode 100644 index 0000000000..109717e099 --- /dev/null +++ b/packages/core/src/app/address/googleAutocomplete/placesNew/utils.ts @@ -0,0 +1,57 @@ +import { type AutocompleteItem } from '@bigcommerce/checkout/ui'; + +import { type GoogleAutocompleteOptionTypes } from '../googleAutocompleteTypes'; + +/** + * Maps legacy Places API collection filters to valid Table A/B primary types + * accepted by the new AutocompleteSuggestion API. + * + * - 'geocode' has no direct equivalent; omitting includedPrimaryTypes gives + * broad address/region results — the same intent as the legacy filter. + * - 'address' maps to 'street_address' for address-only results. + * - 'establishment' remains valid in Table B. + */ +const LEGACY_TYPE_MAP: Record = { + geocode: null, + address: 'street_address', + establishment: 'establishment', +}; + +export const mapToIncludedPrimaryTypes = ( + types: GoogleAutocompleteOptionTypes[], +): string[] | undefined => { + const mapped = types.map((t) => LEGACY_TYPE_MAP[t]).filter((t): t is string => t !== null); + + return mapped.length > 0 ? mapped : undefined; +}; + +export const mapToAutocompleteItems = ( + suggestions: google.maps.places.AutocompleteSuggestion[], +): AutocompleteItem[] => + suggestions.flatMap((suggestion) => { + const { placePrediction } = suggestion; + + if (!placePrediction) return []; + + return [ + { + label: placePrediction.text?.text ?? '', + value: placePrediction.mainText?.text ?? '', + highlightedSlices: (placePrediction.text?.matches ?? []).map( + ({ startOffset, endOffset }) => ({ + offset: startOffset, + length: endOffset - startOffset, + }), + ), + id: placePrediction.placeId, + }, + ]; + }); + +export const mapToGeocoderAddressComponent = ( + c: google.maps.places.AddressComponent, +): google.maps.GeocoderAddressComponent => ({ + long_name: c.longText ?? '', + short_name: c.shortText ?? '', + types: c.types ?? [], +}); diff --git a/packages/core/src/app/address/googleAutocomplete/utils.test.ts b/packages/core/src/app/address/googleAutocomplete/utils.test.ts index e9bf9fcc5d..bf74b289b4 100644 --- a/packages/core/src/app/address/googleAutocomplete/utils.test.ts +++ b/packages/core/src/app/address/googleAutocomplete/utils.test.ts @@ -1,8 +1,10 @@ +// Tests have moved to placesNew/utils.test.ts. +// This file is kept as a shim; all assertions run against the re-exported functions. import { mapToAutocompleteItems, mapToGeocoderAddressComponent, mapToIncludedPrimaryTypes, -} from './utils'; +} from './placesNew/utils'; describe('mapToIncludedPrimaryTypes', () => { it('maps "address" to "street_address"', () => { diff --git a/packages/core/src/app/address/googleAutocomplete/utils.ts b/packages/core/src/app/address/googleAutocomplete/utils.ts index 5c18745bfe..3ee28b6033 100644 --- a/packages/core/src/app/address/googleAutocomplete/utils.ts +++ b/packages/core/src/app/address/googleAutocomplete/utils.ts @@ -1,57 +1,7 @@ -import { type AutocompleteItem } from '@bigcommerce/checkout/ui'; - -import { type GoogleAutocompleteOptionTypes } from './googleAutocompleteTypes'; - -/** - * Maps legacy Places API collection filters to valid Table A/B primary types - * accepted by the new AutocompleteSuggestion API. - * - * - 'geocode' has no direct equivalent; omitting includedPrimaryTypes gives - * broad address/region results — the same intent as the legacy filter. - * - 'address' maps to 'street_address' for address-only results. - * - 'establishment' remains valid in Table B. - */ -const LEGACY_TYPE_MAP: Record = { - geocode: null, - address: 'street_address', - establishment: 'establishment', -}; - -export const mapToIncludedPrimaryTypes = ( - types: GoogleAutocompleteOptionTypes[], -): string[] | undefined => { - const mapped = types.map((t) => LEGACY_TYPE_MAP[t]).filter((t): t is string => t !== null); - - return mapped.length > 0 ? mapped : undefined; -}; - -export const mapToAutocompleteItems = ( - suggestions: google.maps.places.AutocompleteSuggestion[], -): AutocompleteItem[] => - suggestions.flatMap((suggestion) => { - const { placePrediction } = suggestion; - - if (!placePrediction) return []; - - return [ - { - label: placePrediction.text?.text ?? '', - value: placePrediction.mainText?.text ?? '', - highlightedSlices: (placePrediction.text?.matches ?? []).map( - ({ startOffset, endOffset }) => ({ - offset: startOffset, - length: endOffset - startOffset, - }), - ), - id: placePrediction.placeId, - }, - ]; - }); - -export const mapToGeocoderAddressComponent = ( - c: google.maps.places.AddressComponent, -): google.maps.GeocoderAddressComponent => ({ - long_name: c.longText ?? '', - short_name: c.shortText ?? '', - types: c.types ?? [], -}); +// These utilities belong to the Places API (New) implementation. +// They live in placesNew/utils.ts — this file re-exports them for backwards compatibility. +export { + mapToAutocompleteItems, + mapToGeocoderAddressComponent, + mapToIncludedPrimaryTypes, +} from './placesNew/utils'; From 491717a6fcdffdede7569fc2d6942fc72e4e8aac Mon Sep 17 00:00:00 2001 From: bc-maxy Date: Wed, 24 Jun 2026 12:09:34 +1000 Subject: [PATCH 09/21] chore(checkout): CHECKOUT-10026 Clean up the code --- .../googleAutocomplete/GoogleAutocomplete.tsx | 95 +++++++++++-------- .../GoogleAutocompleteFormField.tsx | 6 +- .../GoogleAutocompleteService.test.ts | 5 - .../GoogleAutocompleteService.ts | 49 +--------- .../IGoogleAutocompleteService.ts | 24 ----- .../app/address/googleAutocomplete/index.ts | 7 +- .../GoogleAutocompleteScriptLoader.ts | 0 .../GoogleAutocompleteService.test.ts | 5 +- .../GoogleAutocompleteService.ts | 10 +- .../getGoogleAutocompleteScriptLoader.ts | 0 .../utils.test.ts | 0 .../utils.ts | 0 .../address/googleAutocomplete/utils.test.ts | 90 ------------------ .../app/address/googleAutocomplete/utils.ts | 7 -- 14 files changed, 70 insertions(+), 228 deletions(-) delete mode 100644 packages/core/src/app/address/googleAutocomplete/GoogleAutocompleteService.test.ts delete mode 100644 packages/core/src/app/address/googleAutocomplete/IGoogleAutocompleteService.ts rename packages/core/src/app/address/googleAutocomplete/{placesNew => placesApiGoogleAutocomplete}/GoogleAutocompleteScriptLoader.ts (100%) rename packages/core/src/app/address/googleAutocomplete/{placesNew => placesApiGoogleAutocomplete}/GoogleAutocompleteService.test.ts (98%) rename packages/core/src/app/address/googleAutocomplete/{placesNew => placesApiGoogleAutocomplete}/GoogleAutocompleteService.ts (87%) rename packages/core/src/app/address/googleAutocomplete/{placesNew => placesApiGoogleAutocomplete}/getGoogleAutocompleteScriptLoader.ts (100%) rename packages/core/src/app/address/googleAutocomplete/{placesNew => placesApiGoogleAutocomplete}/utils.test.ts (100%) rename packages/core/src/app/address/googleAutocomplete/{placesNew => placesApiGoogleAutocomplete}/utils.ts (100%) delete mode 100644 packages/core/src/app/address/googleAutocomplete/utils.test.ts delete mode 100644 packages/core/src/app/address/googleAutocomplete/utils.ts diff --git a/packages/core/src/app/address/googleAutocomplete/GoogleAutocomplete.tsx b/packages/core/src/app/address/googleAutocomplete/GoogleAutocomplete.tsx index 414873a70b..ebc4c45343 100644 --- a/packages/core/src/app/address/googleAutocomplete/GoogleAutocomplete.tsx +++ b/packages/core/src/app/address/googleAutocomplete/GoogleAutocomplete.tsx @@ -3,8 +3,7 @@ import React, { useRef, useState } from 'react'; import { Autocomplete, type AutocompleteItem } from '@bigcommerce/checkout/ui'; -import type { IGoogleAutocompleteService } from './IGoogleAutocompleteService'; -import { GoogleAutocompleteService as PlacesNewService } from './placesNew/GoogleAutocompleteService'; +import GoogleAutocompleteService from './GoogleAutocompleteService'; import { type GoogleAutocompleteOptionTypes } from './googleAutocompleteTypes'; import './GoogleAutocomplete.scss'; @@ -17,17 +16,23 @@ export interface GoogleAutocompleteProps { inputProps?: any; isAutocompleteEnabled?: boolean; types?: GoogleAutocompleteOptionTypes[]; - /** - * Override the autocomplete backend. Defaults to the Places API (New) implementation. - * Pass `new GoogleAutocompleteService(apiKey)` (legacy) to use the old Places API. - */ - service?: IGoogleAutocompleteService; onSelect?(place: google.maps.places.PlaceResult, item: AutocompleteItem): void; onToggleOpen?(state: { inputValue: string; isOpen: boolean }): void; onChange?(value: string, isOpen: boolean): void; } -export const GoogleAutocomplete: React.FC = ({ +const toAutocompleteItems = ( + results?: google.maps.places.AutocompletePrediction[], +): AutocompleteItem[] => { + return (results || []).map((result) => ({ + label: result.description, + value: result.structured_formatting.main_text, + highlightedSlices: result.matched_substrings, + id: result.place_id, + })); +}; + +const GoogleAutocomplete: React.FC = ({ initialValue, onToggleOpen = noop, inputProps = {}, @@ -39,34 +44,35 @@ export const GoogleAutocomplete: React.FC = ({ componentRestrictions, types, apiKey, - service, }) => { const [items, setItems] = useState([]); const [autoComplete, setAutoComplete] = useState('off'); - const serviceRef = useRef(); + const googleAutocompleteServiceRef = useRef(); - if (!serviceRef.current) { - serviceRef.current = service ?? new PlacesNewService(apiKey); + if (!googleAutocompleteServiceRef.current) { + googleAutocompleteServiceRef.current = new GoogleAutocompleteService(apiKey); } - const handleSelect = (item: AutocompleteItem) => { - const svc = serviceRef.current; - - if (!svc) return; - - svc.getPlaceDetails(item.id, fields) - .then((placeResult) => { - if (nextElement) { - nextElement.focus(); - } - - onSelect(placeResult, item); - }) - .catch(() => { - // keep user suggestion text if api call fails - onSelect({ name: item.label }, item); - resetAutocomplete(); - }); + const onSelectHandler = (item: AutocompleteItem) => { + const service = googleAutocompleteServiceRef.current; + + if (!service) return; + + service.getPlacesServices().then((placesService) => { + placesService.getDetails( + { + placeId: item.id, + fields: fields || ['address_components', 'name'], + }, + (result) => { + if (nextElement) { + nextElement.focus(); + } + + onSelect(result, item); + }, + ); + }); }; const resetAutocomplete = (): void => { @@ -85,16 +91,27 @@ export const GoogleAutocomplete: React.FC = ({ return; } - const svc = serviceRef.current; + const service = googleAutocompleteServiceRef.current; + + if (!service) return; - if (!svc) return; + service.getAutocompleteService().then((autocompleteService) => { + autocompleteService.getPlacePredictions( + { + input, + types: types || ['geocode'], + componentRestrictions, + }, + (results) => { + const autocompleteItems = toAutocompleteItems(results ?? undefined); - svc.getSuggestions(input, types, componentRestrictions) - .then((suggestions) => setItems(suggestions)) - .catch(() => setItems([])); + setItems(autocompleteItems); + }, + ); + }); }; - const handleChange = (input: string) => { + const onChangeHandler = (input: string) => { onChange(input, false); if (!isAutocompleteEnabled) { @@ -116,11 +133,13 @@ export const GoogleAutocomplete: React.FC = ({ }} items={items} listTestId="address-autocomplete-suggestions" - onChange={handleChange} - onSelect={handleSelect} + onChange={onChangeHandler} + onSelect={onSelectHandler} onToggleOpen={onToggleOpen} >
); }; + +export default GoogleAutocomplete; diff --git a/packages/core/src/app/address/googleAutocomplete/GoogleAutocompleteFormField.tsx b/packages/core/src/app/address/googleAutocomplete/GoogleAutocompleteFormField.tsx index d266ad1eda..2fec8edc00 100644 --- a/packages/core/src/app/address/googleAutocomplete/GoogleAutocompleteFormField.tsx +++ b/packages/core/src/app/address/googleAutocomplete/GoogleAutocompleteFormField.tsx @@ -11,7 +11,7 @@ import { getAddressFormFieldLabelId, } from '../getAddressFormFieldInputId'; -import { GoogleAutocomplete } from './GoogleAutocomplete'; +import GoogleAutocomplete from './GoogleAutocomplete'; export interface GoogleAutocompleteFormFieldProps { apiKey: string; @@ -50,12 +50,12 @@ const GoogleAutocompleteFormField: FunctionComponent { - it.todo('add tests for the legacy Places API service'); -}); diff --git a/packages/core/src/app/address/googleAutocomplete/GoogleAutocompleteService.ts b/packages/core/src/app/address/googleAutocomplete/GoogleAutocompleteService.ts index cb6d86bbe3..ae5ce4840a 100644 --- a/packages/core/src/app/address/googleAutocomplete/GoogleAutocompleteService.ts +++ b/packages/core/src/app/address/googleAutocomplete/GoogleAutocompleteService.ts @@ -1,20 +1,7 @@ -import type { AutocompleteItem } from '@bigcommerce/checkout/ui'; - import getGoogleAutocompleteScriptLoader from './getGoogleAutocompleteScriptLoader'; import type GoogleAutocompleteScriptLoader from './GoogleAutocompleteScriptLoader'; -import type { IGoogleAutocompleteService } from './IGoogleAutocompleteService'; - -const toAutocompleteItems = ( - results?: google.maps.places.AutocompletePrediction[], -): AutocompleteItem[] => - (results || []).map((result) => ({ - label: result.description, - value: result.structured_formatting.main_text, - highlightedSlices: result.matched_substrings, - id: result.place_id, - })); -export default class GoogleAutocompleteService implements IGoogleAutocompleteService { +export default class GoogleAutocompleteService { private _autocompletePromise?: Promise; private _placesPromise?: Promise; @@ -23,40 +10,6 @@ export default class GoogleAutocompleteService implements IGoogleAutocompleteSer private _scriptLoader: GoogleAutocompleteScriptLoader = getGoogleAutocompleteScriptLoader(), ) {} - getSuggestions( - input: string, - types: string[] | undefined, - componentRestrictions?: google.maps.places.ComponentRestrictions, - ): Promise { - return this.getAutocompleteService().then( - (autocompleteService) => - new Promise((resolve) => { - autocompleteService.getPlacePredictions( - { input, types: types || ['geocode'], componentRestrictions }, - (results) => resolve(toAutocompleteItems(results ?? undefined)), - ); - }), - ); - } - - getPlaceDetails( - placeId: string, - fields?: string[], - ): Promise { - return this.getPlacesServices().then( - (placesService) => - new Promise((resolve) => { - placesService.getDetails( - { - placeId, - fields: fields?.length ? fields : ['address_components', 'name'], - }, - (result) => resolve(result ?? {}), - ); - }), - ); - } - getAutocompleteService(): Promise { if (!this._autocompletePromise) { this._autocompletePromise = this._scriptLoader diff --git a/packages/core/src/app/address/googleAutocomplete/IGoogleAutocompleteService.ts b/packages/core/src/app/address/googleAutocomplete/IGoogleAutocompleteService.ts deleted file mode 100644 index 7d3af871e8..0000000000 --- a/packages/core/src/app/address/googleAutocomplete/IGoogleAutocompleteService.ts +++ /dev/null @@ -1,24 +0,0 @@ -import type { AutocompleteItem } from '@bigcommerce/checkout/ui'; - -/** - * Common interface for Google Autocomplete service implementations. - * - * Two implementations exist: - * - `GoogleAutocompleteService` (root) — legacy Places API (callback-based) - * - `placesNew/GoogleAutocompleteService` — Places API (New), promise-based - * - * Pass an instance via the `service` prop on `GoogleAutocomplete` to select - * which backend to use. - */ -export interface IGoogleAutocompleteService { - getSuggestions( - input: string, - types: string[] | undefined, - componentRestrictions?: google.maps.places.ComponentRestrictions, - ): Promise; - - getPlaceDetails( - placeId: string, - fields?: string[], - ): Promise; -} diff --git a/packages/core/src/app/address/googleAutocomplete/index.ts b/packages/core/src/app/address/googleAutocomplete/index.ts index de4f172d08..5114317360 100644 --- a/packages/core/src/app/address/googleAutocomplete/index.ts +++ b/packages/core/src/app/address/googleAutocomplete/index.ts @@ -1,10 +1,5 @@ export * from './googleAutocompleteTypes'; export { default as mapToAddress } from './mapToAddress'; export { default as GoogleAutocompleteScriptLoader } from './GoogleAutocompleteScriptLoader'; -export { GoogleAutocomplete } from './GoogleAutocomplete'; +export { default as GoogleAutocomplete } from './GoogleAutocomplete'; export { default as GoogleAutocompleteFormField } from './GoogleAutocompleteFormField'; -export type { IGoogleAutocompleteService } from './IGoogleAutocompleteService'; -export { GoogleAutocompleteService as PlacesNewGoogleAutocompleteService } from './placesNew/GoogleAutocompleteService'; -export { - GoogleAutocompleteScriptLoader as PlacesNewGoogleAutocompleteScriptLoader, -} from './placesNew/GoogleAutocompleteScriptLoader'; diff --git a/packages/core/src/app/address/googleAutocomplete/placesNew/GoogleAutocompleteScriptLoader.ts b/packages/core/src/app/address/googleAutocomplete/placesApiGoogleAutocomplete/GoogleAutocompleteScriptLoader.ts similarity index 100% rename from packages/core/src/app/address/googleAutocomplete/placesNew/GoogleAutocompleteScriptLoader.ts rename to packages/core/src/app/address/googleAutocomplete/placesApiGoogleAutocomplete/GoogleAutocompleteScriptLoader.ts diff --git a/packages/core/src/app/address/googleAutocomplete/placesNew/GoogleAutocompleteService.test.ts b/packages/core/src/app/address/googleAutocomplete/placesApiGoogleAutocomplete/GoogleAutocompleteService.test.ts similarity index 98% rename from packages/core/src/app/address/googleAutocomplete/placesNew/GoogleAutocompleteService.test.ts rename to packages/core/src/app/address/googleAutocomplete/placesApiGoogleAutocomplete/GoogleAutocompleteService.test.ts index d7ec509cf7..a9f5460ed2 100644 --- a/packages/core/src/app/address/googleAutocomplete/placesNew/GoogleAutocompleteService.test.ts +++ b/packages/core/src/app/address/googleAutocomplete/placesApiGoogleAutocomplete/GoogleAutocompleteService.test.ts @@ -139,7 +139,10 @@ describe('GoogleAutocompleteService', () => { }); it('returns place details mapped to PlaceResult', async () => { - const result = await service.getPlaceDetails('place-1', ['addressComponents', 'displayName']); + const result = await service.getPlaceDetails('place-1', [ + 'addressComponents', + 'displayName', + ]); expect(result).toEqual({ address_components: [], diff --git a/packages/core/src/app/address/googleAutocomplete/placesNew/GoogleAutocompleteService.ts b/packages/core/src/app/address/googleAutocomplete/placesApiGoogleAutocomplete/GoogleAutocompleteService.ts similarity index 87% rename from packages/core/src/app/address/googleAutocomplete/placesNew/GoogleAutocompleteService.ts rename to packages/core/src/app/address/googleAutocomplete/placesApiGoogleAutocomplete/GoogleAutocompleteService.ts index 4d50d1226f..5769f0f28d 100644 --- a/packages/core/src/app/address/googleAutocomplete/placesNew/GoogleAutocompleteService.ts +++ b/packages/core/src/app/address/googleAutocomplete/placesApiGoogleAutocomplete/GoogleAutocompleteService.ts @@ -1,7 +1,7 @@ import type { AutocompleteItem } from '@bigcommerce/checkout/ui'; -import type { IGoogleAutocompleteService } from '../IGoogleAutocompleteService'; import type { GoogleAutocompleteOptionTypes } from '../googleAutocompleteTypes'; + import { getGoogleAutocompleteScriptLoader } from './getGoogleAutocompleteScriptLoader'; import type { GoogleAutocompleteScriptLoader } from './GoogleAutocompleteScriptLoader'; import { @@ -10,7 +10,7 @@ import { mapToIncludedPrimaryTypes, } from './utils'; -export class GoogleAutocompleteService implements IGoogleAutocompleteService { +export class GoogleAutocompleteService { private _sessionToken?: google.maps.places.AutocompleteSessionToken; constructor( @@ -59,12 +59,10 @@ export class GoogleAutocompleteService implements IGoogleAutocompleteService { const place = new Place({ id: placeId }); - // sessionToken is a valid runtime property but absent from the type definition await place.fetchFields({ fields: fields?.length ? fields : ['addressComponents', 'displayName'], - // eslint-disable-next-line @typescript-eslint/no-explicit-any - sessionToken: sessionToken as any, - } as any); + sessionToken, + }); return { address_components: place.addressComponents?.map(mapToGeocoderAddressComponent), diff --git a/packages/core/src/app/address/googleAutocomplete/placesNew/getGoogleAutocompleteScriptLoader.ts b/packages/core/src/app/address/googleAutocomplete/placesApiGoogleAutocomplete/getGoogleAutocompleteScriptLoader.ts similarity index 100% rename from packages/core/src/app/address/googleAutocomplete/placesNew/getGoogleAutocompleteScriptLoader.ts rename to packages/core/src/app/address/googleAutocomplete/placesApiGoogleAutocomplete/getGoogleAutocompleteScriptLoader.ts diff --git a/packages/core/src/app/address/googleAutocomplete/placesNew/utils.test.ts b/packages/core/src/app/address/googleAutocomplete/placesApiGoogleAutocomplete/utils.test.ts similarity index 100% rename from packages/core/src/app/address/googleAutocomplete/placesNew/utils.test.ts rename to packages/core/src/app/address/googleAutocomplete/placesApiGoogleAutocomplete/utils.test.ts diff --git a/packages/core/src/app/address/googleAutocomplete/placesNew/utils.ts b/packages/core/src/app/address/googleAutocomplete/placesApiGoogleAutocomplete/utils.ts similarity index 100% rename from packages/core/src/app/address/googleAutocomplete/placesNew/utils.ts rename to packages/core/src/app/address/googleAutocomplete/placesApiGoogleAutocomplete/utils.ts diff --git a/packages/core/src/app/address/googleAutocomplete/utils.test.ts b/packages/core/src/app/address/googleAutocomplete/utils.test.ts deleted file mode 100644 index bf74b289b4..0000000000 --- a/packages/core/src/app/address/googleAutocomplete/utils.test.ts +++ /dev/null @@ -1,90 +0,0 @@ -// Tests have moved to placesNew/utils.test.ts. -// This file is kept as a shim; all assertions run against the re-exported functions. -import { - mapToAutocompleteItems, - mapToGeocoderAddressComponent, - mapToIncludedPrimaryTypes, -} from './placesNew/utils'; - -describe('mapToIncludedPrimaryTypes', () => { - it('maps "address" to "street_address"', () => { - expect(mapToIncludedPrimaryTypes(['address'])).toEqual(['street_address']); - }); - - it('keeps "establishment" as-is', () => { - expect(mapToIncludedPrimaryTypes(['establishment'])).toEqual(['establishment']); - }); - - it('returns undefined for "geocode" (no direct equivalent)', () => { - expect(mapToIncludedPrimaryTypes(['geocode'])).toBeUndefined(); - }); - - it('filters out null-mapped types and returns the rest', () => { - expect(mapToIncludedPrimaryTypes(['geocode', 'address'])).toEqual(['street_address']); - }); - - it('returns undefined for an empty array', () => { - expect(mapToIncludedPrimaryTypes([])).toBeUndefined(); - }); -}); - -describe('mapToAutocompleteItems', () => { - it('maps a suggestion to an autocomplete item', () => { - const mockSuggestions: google.maps.places.AutocompleteSuggestion[] = [ - { - placePrediction: { - placeId: 'place-1', - text: { - text: '123 Main St, New York', - matches: [{ startOffset: 0, endOffset: 3 }], - }, - mainText: { text: '123 Main St' }, - } as google.maps.places.PlacePrediction, - }, - ]; - - const items = mapToAutocompleteItems(mockSuggestions); - - expect(items).toHaveLength(1); - expect(items[0]).toMatchObject({ - id: 'place-1', - label: '123 Main St, New York', - value: '123 Main St', - highlightedSlices: [{ offset: 0, length: 3 }], - }); - }); - - it('skips suggestions without placePrediction', () => { - const mockSuggestionsWithoutPlacePrediction: google.maps.places.AutocompleteSuggestion[] = [ - { placePrediction: null }, - ]; - - expect(mapToAutocompleteItems(mockSuggestionsWithoutPlacePrediction)).toEqual([]); - }); -}); - -describe('mapToGeocoderAddressComponent', () => { - it('maps an AddressComponent to a GeocoderAddressComponent', () => { - const addressComponentMock = { - longText: 'New York', - shortText: 'NY', - types: ['locality', 'political'], - } as google.maps.places.AddressComponent; - - expect(mapToGeocoderAddressComponent(addressComponentMock)).toEqual({ - long_name: 'New York', - short_name: 'NY', - types: ['locality', 'political'], - }); - }); - - it('falls back to empty strings and empty array when fields are missing', () => { - const component = {} as google.maps.places.AddressComponent; - - expect(mapToGeocoderAddressComponent(component)).toEqual({ - long_name: '', - short_name: '', - types: [], - }); - }); -}); diff --git a/packages/core/src/app/address/googleAutocomplete/utils.ts b/packages/core/src/app/address/googleAutocomplete/utils.ts deleted file mode 100644 index 3ee28b6033..0000000000 --- a/packages/core/src/app/address/googleAutocomplete/utils.ts +++ /dev/null @@ -1,7 +0,0 @@ -// These utilities belong to the Places API (New) implementation. -// They live in placesNew/utils.ts — this file re-exports them for backwards compatibility. -export { - mapToAutocompleteItems, - mapToGeocoderAddressComponent, - mapToIncludedPrimaryTypes, -} from './placesNew/utils'; From dd051c8f487abad0233306330d5280a9146d47d3 Mon Sep 17 00:00:00 2001 From: bc-maxy Date: Wed, 24 Jun 2026 12:38:07 +1000 Subject: [PATCH 10/21] feat(checkout): CHECKOUT-10026 Fall back to new service if old one fails --- .../GoogleAutocomplete.test.tsx | 177 ++++++++++++++++++ .../googleAutocomplete/GoogleAutocomplete.tsx | 61 +++++- 2 files changed, 231 insertions(+), 7 deletions(-) create mode 100644 packages/core/src/app/address/googleAutocomplete/GoogleAutocomplete.test.tsx diff --git a/packages/core/src/app/address/googleAutocomplete/GoogleAutocomplete.test.tsx b/packages/core/src/app/address/googleAutocomplete/GoogleAutocomplete.test.tsx new file mode 100644 index 0000000000..caba08a01f --- /dev/null +++ b/packages/core/src/app/address/googleAutocomplete/GoogleAutocomplete.test.tsx @@ -0,0 +1,177 @@ +import userEvent from '@testing-library/user-event'; +import React from 'react'; + +import { render, screen, waitFor } from '@bigcommerce/checkout/test-utils'; + +import GoogleAutocomplete, { type GoogleAutocompleteProps } from './GoogleAutocomplete'; +import LegacyGoogleAutocompleteService from './GoogleAutocompleteService'; +import { GoogleAutocompleteService as PlacesApiService } from './placesApiGoogleAutocomplete/GoogleAutocompleteService'; + +jest.mock('./GoogleAutocompleteService'); +jest.mock('./placesApiGoogleAutocomplete/GoogleAutocompleteService'); + +const MockLegacyService = LegacyGoogleAutocompleteService as jest.MockedClass< + typeof LegacyGoogleAutocompleteService +>; +const MockPlacesApiService = PlacesApiService as jest.MockedClass; + +const legacySuggestions = [ + { + description: '123 Legacy St, New York', + structured_formatting: { main_text: '123 Legacy St' }, + matched_substrings: [], + place_id: 'legacy-place-1', + }, +] as unknown as google.maps.places.AutocompletePrediction[]; + +const legacyPlaceResult = { + name: '123 Legacy St', + address_components: [], +} as google.maps.places.PlaceResult; + +const newApiSuggestions = [{ id: 'new-place-1', label: '123 New API Ave', value: '123 New API Ave' }]; + +const newApiPlaceResult = { + name: '123 New API Ave', + address_components: [], +} as google.maps.places.PlaceResult; + +describe('GoogleAutocomplete', () => { + let mockGetPlacePredictions: jest.Mock; + let mockGetDetails: jest.Mock; + let mockGetSuggestions: jest.Mock; + let mockGetPlaceDetails: jest.Mock; + let defaultProps: GoogleAutocompleteProps; + + beforeEach(() => { + mockGetPlacePredictions = jest.fn(); + mockGetDetails = jest.fn(); + mockGetSuggestions = jest.fn().mockResolvedValue(newApiSuggestions); + mockGetPlaceDetails = jest.fn().mockResolvedValue(newApiPlaceResult); + + MockLegacyService.mockImplementation( + () => + ({ + getAutocompleteService: jest + .fn() + .mockResolvedValue({ getPlacePredictions: mockGetPlacePredictions }), + getPlacesServices: jest + .fn() + .mockResolvedValue({ getDetails: mockGetDetails }), + }) as unknown as LegacyGoogleAutocompleteService, + ); + + MockPlacesApiService.mockImplementation( + () => + ({ + getSuggestions: mockGetSuggestions, + getPlaceDetails: mockGetPlaceDetails, + }) as unknown as PlacesApiService, + ); + + defaultProps = { + apiKey: 'test-api-key', + isAutocompleteEnabled: true, + onSelect: jest.fn(), + onChange: jest.fn(), + }; + }); + + describe('legacy API available (existing customers)', () => { + beforeEach(() => { + mockGetPlacePredictions.mockImplementation((_req, cb) => + cb(legacySuggestions, 'OK'), + ); + mockGetDetails.mockImplementation((_req, cb) => cb(legacyPlaceResult, 'OK')); + }); + + it('shows suggestions from the legacy service', async () => { + render(); + + await userEvent.type(screen.getByRole('textbox'), '123'); + + await waitFor(() => + expect(screen.getByText('123 Legacy St, New York')).toBeInTheDocument(), + ); + expect(mockGetSuggestions).not.toHaveBeenCalled(); + }); + + it('calls onSelect with the legacy place result when a suggestion is picked', async () => { + render(); + + await userEvent.type(screen.getByRole('textbox'), '123'); + await waitFor(() => + expect(screen.getByText('123 Legacy St, New York')).toBeInTheDocument(), + ); + await userEvent.click(screen.getByText('123 Legacy St, New York')); + + await waitFor(() => + expect(defaultProps.onSelect).toHaveBeenCalledWith( + legacyPlaceResult, + expect.objectContaining({ id: 'legacy-place-1' }), + ), + ); + expect(mockGetPlaceDetails).not.toHaveBeenCalled(); + }); + }); + + describe('legacy API unavailable (new customers)', () => { + beforeEach(() => { + mockGetPlacePredictions.mockImplementation((_req, cb) => + cb(null, 'REQUEST_DENIED'), + ); + mockGetDetails.mockImplementation((_req, cb) => cb(null, 'REQUEST_DENIED')); + }); + + it('falls back to the new API for suggestions on REQUEST_DENIED', async () => { + render(); + + await userEvent.type(screen.getByRole('textbox'), '123'); + + await waitFor(() => + expect(screen.getByText('123 New API Ave')).toBeInTheDocument(), + ); + expect(mockGetSuggestions).toHaveBeenCalledWith( + expect.stringContaining('123'), + undefined, + undefined, + ); + }); + + it('skips the legacy service on subsequent keystrokes after REQUEST_DENIED', async () => { + render(); + + await userEvent.type(screen.getByRole('textbox'), '12'); + + await waitFor(() => expect(mockGetSuggestions).toHaveBeenCalledTimes(2)); + + // Both characters triggered the new API; legacy was only tried once (on '1') + expect(mockGetPlacePredictions).toHaveBeenCalledTimes(1); + }); + + it('falls back to the new API for place details on REQUEST_DENIED', async () => { + // Return OK for suggestions so the dropdown has an item to click, + // but REQUEST_DENIED for getDetails (set in beforeEach). + mockGetPlacePredictions.mockImplementation((_req, cb) => + cb(legacySuggestions, 'OK'), + ); + + render(); + + // Type a single character so only one getPlacePredictions call fires + await userEvent.type(screen.getByRole('textbox'), '1'); + await waitFor(() => + expect(screen.getByText('123 Legacy St, New York')).toBeInTheDocument(), + ); + await userEvent.click(screen.getByText('123 Legacy St, New York')); + + await waitFor(() => + expect(defaultProps.onSelect).toHaveBeenCalledWith( + newApiPlaceResult, + expect.objectContaining({ id: 'legacy-place-1' }), + ), + ); + expect(mockGetPlaceDetails).toHaveBeenCalledWith('legacy-place-1', undefined); + }); + }); +}); diff --git a/packages/core/src/app/address/googleAutocomplete/GoogleAutocomplete.tsx b/packages/core/src/app/address/googleAutocomplete/GoogleAutocomplete.tsx index ebc4c45343..0bd187e710 100644 --- a/packages/core/src/app/address/googleAutocomplete/GoogleAutocomplete.tsx +++ b/packages/core/src/app/address/googleAutocomplete/GoogleAutocomplete.tsx @@ -5,6 +5,7 @@ import { Autocomplete, type AutocompleteItem } from '@bigcommerce/checkout/ui'; import GoogleAutocompleteService from './GoogleAutocompleteService'; import { type GoogleAutocompleteOptionTypes } from './googleAutocompleteTypes'; +import { GoogleAutocompleteService as PlacesApiGoogleAutocompleteService } from './placesApiGoogleAutocomplete/GoogleAutocompleteService'; import './GoogleAutocomplete.scss'; export interface GoogleAutocompleteProps { @@ -48,23 +49,54 @@ const GoogleAutocomplete: React.FC = ({ const [items, setItems] = useState([]); const [autoComplete, setAutoComplete] = useState('off'); const googleAutocompleteServiceRef = useRef(); + const placesApiServiceRef = useRef(); + const isLegacyUnavailableRef = useRef(false); if (!googleAutocompleteServiceRef.current) { googleAutocompleteServiceRef.current = new GoogleAutocompleteService(apiKey); } + if (!placesApiServiceRef.current) { + placesApiServiceRef.current = new PlacesApiGoogleAutocompleteService(apiKey); + } + const onSelectHandler = (item: AutocompleteItem) => { - const service = googleAutocompleteServiceRef.current; + if (isLegacyUnavailableRef.current) { + placesApiServiceRef.current! + .getPlaceDetails(item.id, fields) + .then((result) => { + if (nextElement) { + nextElement.focus(); + } - if (!service) return; + onSelect(result, item); + }); + + return; + } - service.getPlacesServices().then((placesService) => { + googleAutocompleteServiceRef.current!.getPlacesServices().then((placesService) => { placesService.getDetails( { placeId: item.id, fields: fields || ['address_components', 'name'], }, - (result) => { + (result, status) => { + if (status === 'REQUEST_DENIED') { + isLegacyUnavailableRef.current = true; + placesApiServiceRef.current! + .getPlaceDetails(item.id, fields) + .then((newResult) => { + if (nextElement) { + nextElement.focus(); + } + + onSelect(newResult, item); + }); + + return; + } + if (nextElement) { nextElement.focus(); } @@ -95,6 +127,14 @@ const GoogleAutocomplete: React.FC = ({ if (!service) return; + if (isLegacyUnavailableRef.current) { + placesApiServiceRef.current! + .getSuggestions(input, types, componentRestrictions) + .then(setItems); + + return; + } + service.getAutocompleteService().then((autocompleteService) => { autocompleteService.getPlacePredictions( { @@ -102,10 +142,17 @@ const GoogleAutocomplete: React.FC = ({ types: types || ['geocode'], componentRestrictions, }, - (results) => { - const autocompleteItems = toAutocompleteItems(results ?? undefined); + (results, status) => { + if (status === 'REQUEST_DENIED') { + isLegacyUnavailableRef.current = true; + placesApiServiceRef.current! + .getSuggestions(input, types, componentRestrictions) + .then(setItems); + + return; + } - setItems(autocompleteItems); + setItems(toAutocompleteItems(results ?? undefined)); }, ); }); From 0e95de2c7493071b6905eedc9d6222d15088f32d Mon Sep 17 00:00:00 2001 From: bc-maxy Date: Wed, 24 Jun 2026 15:56:26 +1000 Subject: [PATCH 11/21] fix(checkout): CHECKOUT-10026 Adjust functions to work correctly --- .../googleAutocomplete/GoogleAutocomplete.tsx | 152 +++++++++++------- .../GoogleAutocompleteScriptLoader.ts | 17 +- .../GoogleAutocompleteService.test.ts | 87 +++++----- .../GoogleAutocompleteService.ts | 48 +++--- 4 files changed, 184 insertions(+), 120 deletions(-) diff --git a/packages/core/src/app/address/googleAutocomplete/GoogleAutocomplete.tsx b/packages/core/src/app/address/googleAutocomplete/GoogleAutocomplete.tsx index 0bd187e710..c5fd9eeada 100644 --- a/packages/core/src/app/address/googleAutocomplete/GoogleAutocomplete.tsx +++ b/packages/core/src/app/address/googleAutocomplete/GoogleAutocomplete.tsx @@ -1,5 +1,5 @@ import { noop } from 'lodash'; -import React, { useRef, useState } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; import { Autocomplete, type AutocompleteItem } from '@bigcommerce/checkout/ui'; @@ -51,6 +51,7 @@ const GoogleAutocomplete: React.FC = ({ const googleAutocompleteServiceRef = useRef(); const placesApiServiceRef = useRef(); const isLegacyUnavailableRef = useRef(false); + const currentInputRef = useRef(''); if (!googleAutocompleteServiceRef.current) { googleAutocompleteServiceRef.current = new GoogleAutocompleteService(apiKey); @@ -60,51 +61,83 @@ const GoogleAutocomplete: React.FC = ({ placesApiServiceRef.current = new PlacesApiGoogleAutocompleteService(apiKey); } + // gm_authFailure fires when the Maps JS SDK processes an auth error asynchronously + // (e.g. ApiTargetBlockedMapError for keys without legacy Places API access). + // In that case getPlacePredictions' callback never fires, so we detect the failure + // here and immediately retry the current input against the new Places API. + useEffect(() => { + const win = window as Window & { gm_authFailure?: () => void }; + const prev = win.gm_authFailure; + + win.gm_authFailure = () => { + win.gm_authFailure = prev; + isLegacyUnavailableRef.current = true; + + if (currentInputRef.current) { + placesApiServiceRef + .current!.getSuggestions(currentInputRef.current, types, componentRestrictions) + .then(setItems) + .catch(noop); + } + }; + + return () => { + win.gm_authFailure = prev; + }; + }, []); + const onSelectHandler = (item: AutocompleteItem) => { if (isLegacyUnavailableRef.current) { - placesApiServiceRef.current! - .getPlaceDetails(item.id, fields) - .then((result) => { - if (nextElement) { - nextElement.focus(); - } + placesApiServiceRef.current!.getPlaceDetails(item.id, fields).then((result) => { + if (nextElement) { + nextElement.focus(); + } - onSelect(result, item); - }); + onSelect(result, item); + }); return; } - googleAutocompleteServiceRef.current!.getPlacesServices().then((placesService) => { - placesService.getDetails( - { - placeId: item.id, - fields: fields || ['address_components', 'name'], - }, - (result, status) => { - if (status === 'REQUEST_DENIED') { - isLegacyUnavailableRef.current = true; - placesApiServiceRef.current! - .getPlaceDetails(item.id, fields) - .then((newResult) => { - if (nextElement) { - nextElement.focus(); - } - - onSelect(newResult, item); - }); - - return; - } - + googleAutocompleteServiceRef.current! + .getPlacesServices() + .then((placesService) => { + placesService.getDetails( + { placeId: item.id, fields: fields || ['address_components', 'name'] }, + (result, status) => { + if (status === 'REQUEST_DENIED') { + isLegacyUnavailableRef.current = true; + placesApiServiceRef.current! + .getPlaceDetails(item.id, fields) + .then((newResult) => { + if (nextElement) { + nextElement.focus(); + } + + onSelect(newResult, item); + }); + + return; + } + + if (nextElement) { + nextElement.focus(); + } + + onSelect(result, item); + }, + ); + }) + .catch(() => { + isLegacyUnavailableRef.current = true; + placesApiServiceRef.current!.getPlaceDetails(item.id, fields).then((result) => { if (nextElement) { nextElement.focus(); } onSelect(result, item); - }, - ); - }); + }); + }); }; const resetAutocomplete = (): void => { @@ -125,7 +158,9 @@ const GoogleAutocomplete: React.FC = ({ const service = googleAutocompleteServiceRef.current; - if (!service) return; + if (!service) { + return; + } if (isLegacyUnavailableRef.current) { placesApiServiceRef.current! @@ -135,30 +170,35 @@ const GoogleAutocomplete: React.FC = ({ return; } - service.getAutocompleteService().then((autocompleteService) => { - autocompleteService.getPlacePredictions( - { - input, - types: types || ['geocode'], - componentRestrictions, - }, - (results, status) => { - if (status === 'REQUEST_DENIED') { - isLegacyUnavailableRef.current = true; - placesApiServiceRef.current! - .getSuggestions(input, types, componentRestrictions) - .then(setItems); - - return; - } - - setItems(toAutocompleteItems(results ?? undefined)); - }, - ); - }); + service + .getAutocompleteService() + .then((autocompleteService) => { + autocompleteService.getPlacePredictions( + { input, types: types || ['geocode'], componentRestrictions }, + (results, status) => { + if (status === 'REQUEST_DENIED') { + isLegacyUnavailableRef.current = true; + placesApiServiceRef.current! + .getSuggestions(input, types, componentRestrictions) + .then(setItems); + + return; + } + + setItems(toAutocompleteItems(results ?? undefined)); + }, + ); + }) + .catch(() => { + isLegacyUnavailableRef.current = true; + placesApiServiceRef.current! + .getSuggestions(input, types, componentRestrictions) + .then(setItems); + }); }; const onChangeHandler = (input: string) => { + currentInputRef.current = input; onChange(input, false); if (!isAutocompleteEnabled) { diff --git a/packages/core/src/app/address/googleAutocomplete/placesApiGoogleAutocomplete/GoogleAutocompleteScriptLoader.ts b/packages/core/src/app/address/googleAutocomplete/placesApiGoogleAutocomplete/GoogleAutocompleteScriptLoader.ts index 25a720848c..5d2965e8aa 100644 --- a/packages/core/src/app/address/googleAutocomplete/placesApiGoogleAutocomplete/GoogleAutocompleteScriptLoader.ts +++ b/packages/core/src/app/address/googleAutocomplete/placesApiGoogleAutocomplete/GoogleAutocompleteScriptLoader.ts @@ -9,18 +9,23 @@ export class GoogleAutocompleteScriptLoader { return this._placesPromise; } - const params = ['language=en', `key=${apiKey}`, 'loading=async'].join('&'); + // If Maps JS is already on the page (e.g. loaded by the legacy AutocompleteService), + // skip injecting a second script tag — loading the SDK twice corrupts its internal + // state and causes fetchAutocompleteSuggestions to throw. Go straight to importLibrary. + const mapsReady = + typeof google !== 'undefined' && google?.maps + ? Promise.resolve() + : this._scriptLoader.loadScript( + `//maps.googleapis.com/maps/api/js?${['language=en', `key=${apiKey}`, 'loading=async'].join('&')}`, + ); - const promise = this._scriptLoader - .loadScript(`//maps.googleapis.com/maps/api/js?${params}`) + this._placesPromise = mapsReady .then(() => google.maps.importLibrary('places') as Promise) .catch((e) => { this._placesPromise = undefined; throw e; }); - this._placesPromise = promise; - - return promise; + return this._placesPromise; } } diff --git a/packages/core/src/app/address/googleAutocomplete/placesApiGoogleAutocomplete/GoogleAutocompleteService.test.ts b/packages/core/src/app/address/googleAutocomplete/placesApiGoogleAutocomplete/GoogleAutocompleteService.test.ts index a9f5460ed2..c07701b3c7 100644 --- a/packages/core/src/app/address/googleAutocomplete/placesApiGoogleAutocomplete/GoogleAutocompleteService.test.ts +++ b/packages/core/src/app/address/googleAutocomplete/placesApiGoogleAutocomplete/GoogleAutocompleteService.test.ts @@ -11,12 +11,6 @@ const mockSuggestions = [ }, ] as unknown as google.maps.places.AutocompleteSuggestion[]; -const mockPlace = { - fetchFields: jest.fn().mockResolvedValue(undefined), - addressComponents: [], - displayName: '123 Main St', -} as unknown as google.maps.places.Place; - const mockSessionToken = {} as google.maps.places.AutocompleteSessionToken; const mockPlacesLibrary = { @@ -24,19 +18,31 @@ const mockPlacesLibrary = { fetchAutocompleteSuggestions: jest.fn().mockResolvedValue({ suggestions: mockSuggestions }), }, AutocompleteSessionToken: jest.fn().mockImplementation(() => mockSessionToken), - Place: jest.fn().mockImplementation(() => mockPlace), } as unknown as google.maps.PlacesLibrary; const mockScriptLoader = { loadPlacesLibrary: jest.fn().mockResolvedValue(mockPlacesLibrary), } as unknown as GoogleAutocompleteScriptLoader; +const mockPlaceDetailsResponse = { + addressComponents: [ + { longText: 'New South Wales', shortText: 'NSW', types: ['administrative_area_level_1'] }, + { longText: 'Australia', shortText: 'AU', types: ['country'] }, + ], + displayName: { text: '123 Main St' }, +}; + describe('GoogleAutocompleteService', () => { let service: GoogleAutocompleteService; beforeEach(() => { jest.clearAllMocks(); service = new GoogleAutocompleteService('test-api-key', mockScriptLoader); + + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + json: jest.fn().mockResolvedValue(mockPlaceDetailsResponse), + } as unknown as Response); }); describe('#getSuggestions()', () => { @@ -103,58 +109,59 @@ describe('GoogleAutocompleteService', () => { }); describe('#getPlaceDetails()', () => { - it('constructs a Place with the given placeId', async () => { + it('fetches from the Places REST API with the correct URL and field mask', async () => { await service.getPlaceDetails('place-1', ['addressComponents', 'displayName']); - expect(mockPlacesLibrary.Place).toHaveBeenCalledWith({ id: 'place-1' }); + expect(global.fetch).toHaveBeenCalledWith( + 'https://places.googleapis.com/v1/places/place-1?key=test-api-key', + expect.objectContaining({ + headers: { 'X-Goog-FieldMask': 'addressComponents,displayName' }, + }), + ); }); - it('calls fetchFields with the given fields and the active session token', async () => { - await service.getSuggestions('123 Main', ['address']); - await service.getPlaceDetails('place-1', ['addressComponents', 'displayName']); + it('uses default fields when none provided', async () => { + await service.getPlaceDetails('place-1'); - expect(mockPlace.fetchFields).toHaveBeenCalledWith({ - fields: ['addressComponents', 'displayName'], - sessionToken: mockSessionToken, - }); + expect(global.fetch).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + headers: { 'X-Goog-FieldMask': 'addressComponents,displayName' }, + }), + ); }); - it('calls fetchFields with no session token if getSuggestions was never called', async () => { - await service.getPlaceDetails('place-1', ['addressComponents', 'displayName']); + it('maps addressComponents to the legacy GeocoderAddressComponent shape', async () => { + const result = await service.getPlaceDetails('place-1'); + + expect(result.address_components).toEqual([ + { long_name: 'New South Wales', short_name: 'NSW', types: ['administrative_area_level_1'] }, + { long_name: 'Australia', short_name: 'AU', types: ['country'] }, + ]); + }); + + it('maps displayName to the name field', async () => { + const result = await service.getPlaceDetails('place-1'); - expect(mockPlace.fetchFields).toHaveBeenCalledWith({ - fields: ['addressComponents', 'displayName'], - sessionToken: undefined, - }); + expect(result.name).toBe('123 Main St'); }); it('resets the session token after getPlaceDetails so the next session gets a fresh token', async () => { await service.getSuggestions('123 Main', ['address']); await service.getPlaceDetails('place-1', ['addressComponents']); - - // Start a new typing session await service.getSuggestions('456 Oak', ['address']); expect(mockPlacesLibrary.AutocompleteSessionToken).toHaveBeenCalledTimes(2); }); - it('returns place details mapped to PlaceResult', async () => { - const result = await service.getPlaceDetails('place-1', [ - 'addressComponents', - 'displayName', - ]); - - expect(result).toEqual({ - address_components: [], - name: '123 Main St', - }); - }); - - it('uses default fields when none provided', async () => { - await service.getPlaceDetails('place-1'); + it('throws when the REST API returns a non-ok status', async () => { + (global.fetch as jest.Mock).mockResolvedValueOnce({ + ok: false, + status: 403, + } as unknown as Response); - expect(mockPlace.fetchFields).toHaveBeenCalledWith( - expect.objectContaining({ fields: ['addressComponents', 'displayName'] }), + await expect(service.getPlaceDetails('place-1')).rejects.toThrow( + 'Places API request failed with status 403', ); }); }); diff --git a/packages/core/src/app/address/googleAutocomplete/placesApiGoogleAutocomplete/GoogleAutocompleteService.ts b/packages/core/src/app/address/googleAutocomplete/placesApiGoogleAutocomplete/GoogleAutocompleteService.ts index 5769f0f28d..ba6ca7dadd 100644 --- a/packages/core/src/app/address/googleAutocomplete/placesApiGoogleAutocomplete/GoogleAutocompleteService.ts +++ b/packages/core/src/app/address/googleAutocomplete/placesApiGoogleAutocomplete/GoogleAutocompleteService.ts @@ -4,11 +4,16 @@ import type { GoogleAutocompleteOptionTypes } from '../googleAutocompleteTypes'; import { getGoogleAutocompleteScriptLoader } from './getGoogleAutocompleteScriptLoader'; import type { GoogleAutocompleteScriptLoader } from './GoogleAutocompleteScriptLoader'; -import { - mapToAutocompleteItems, - mapToGeocoderAddressComponent, - mapToIncludedPrimaryTypes, -} from './utils'; +import { mapToAutocompleteItems, mapToIncludedPrimaryTypes } from './utils'; + +interface PlacesRestResponse { + addressComponents?: Array<{ + longText?: string; + shortText?: string; + types?: string[]; + }>; + displayName?: { text: string }; +} export class GoogleAutocompleteService { private _sessionToken?: google.maps.places.AutocompleteSessionToken; @@ -49,24 +54,31 @@ export class GoogleAutocompleteService { placeId: string, fields?: string[], ): Promise { - const { Place } = await this._scriptLoader.loadPlacesLibrary(this._apiKey); - - // Consume and reset the session token — the session ends when fetchFields is called. - // A new token will be created automatically on the next getSuggestions call. - const sessionToken = this._sessionToken; - + // Consume and reset the session token this._sessionToken = undefined; - const place = new Place({ id: placeId }); + // Place.fetchFields silently does nothing when Maps JS was loaded via libraries=places + // (ApiTargetBlockedMapError corrupts the SDK transport). Use the Places REST API + // directly — it is completely independent of JS SDK auth state. + const requestedFields = fields?.length ? fields : ['addressComponents', 'displayName']; + const response = await fetch( + `https://places.googleapis.com/v1/places/${encodeURIComponent(placeId)}?key=${this._apiKey}`, + { headers: { 'X-Goog-FieldMask': requestedFields.join(',') } }, + ); - await place.fetchFields({ - fields: fields?.length ? fields : ['addressComponents', 'displayName'], - sessionToken, - }); + if (!response.ok) { + throw new Error(`Places API request failed with status ${response.status}`); + } + + const data = (await response.json()) as PlacesRestResponse; return { - address_components: place.addressComponents?.map(mapToGeocoderAddressComponent), - name: place.displayName ?? '', + address_components: data.addressComponents?.map((c) => ({ + long_name: c.longText ?? '', + short_name: c.shortText ?? '', + types: c.types ?? [], + })), + name: data.displayName?.text ?? '', }; } From 9389b1475b46ca998f71fc57826642d4ce3fb750 Mon Sep 17 00:00:00 2001 From: bc-maxy Date: Thu, 25 Jun 2026 09:42:04 +1000 Subject: [PATCH 12/21] fix(checkout): CHECKOUT-10026 Use module scoped variable --- .../googleAutocomplete/GoogleAutocomplete.tsx | 24 ++++++++++++------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/packages/core/src/app/address/googleAutocomplete/GoogleAutocomplete.tsx b/packages/core/src/app/address/googleAutocomplete/GoogleAutocomplete.tsx index c5fd9eeada..2a4bf0a9f3 100644 --- a/packages/core/src/app/address/googleAutocomplete/GoogleAutocomplete.tsx +++ b/packages/core/src/app/address/googleAutocomplete/GoogleAutocomplete.tsx @@ -33,6 +33,12 @@ const toAutocompleteItems = ( })); }; +// Persisted at module scope so the legacy-unavailable signal survives +// unmount/remount when navigating between Shipping and Billing steps. +// Once the legacy API fails for a given API key it won't recover mid-session, +// so a single module-level flag is safe to share across all instances. +let isLegacyUnavailable = false; + const GoogleAutocomplete: React.FC = ({ initialValue, onToggleOpen = noop, @@ -50,7 +56,6 @@ const GoogleAutocomplete: React.FC = ({ const [autoComplete, setAutoComplete] = useState('off'); const googleAutocompleteServiceRef = useRef(); const placesApiServiceRef = useRef(); - const isLegacyUnavailableRef = useRef(false); const currentInputRef = useRef(''); if (!googleAutocompleteServiceRef.current) { @@ -71,7 +76,7 @@ const GoogleAutocomplete: React.FC = ({ win.gm_authFailure = () => { win.gm_authFailure = prev; - isLegacyUnavailableRef.current = true; + isLegacyUnavailable = true; if (currentInputRef.current) { placesApiServiceRef @@ -87,7 +92,7 @@ const GoogleAutocomplete: React.FC = ({ }, []); const onSelectHandler = (item: AutocompleteItem) => { - if (isLegacyUnavailableRef.current) { + if (isLegacyUnavailable) { placesApiServiceRef.current!.getPlaceDetails(item.id, fields).then((result) => { if (nextElement) { nextElement.focus(); @@ -106,7 +111,7 @@ const GoogleAutocomplete: React.FC = ({ { placeId: item.id, fields: fields || ['address_components', 'name'] }, (result, status) => { if (status === 'REQUEST_DENIED') { - isLegacyUnavailableRef.current = true; + isLegacyUnavailable = true; placesApiServiceRef.current! .getPlaceDetails(item.id, fields) .then((newResult) => { @@ -129,7 +134,7 @@ const GoogleAutocomplete: React.FC = ({ ); }) .catch(() => { - isLegacyUnavailableRef.current = true; + isLegacyUnavailable = true; placesApiServiceRef.current!.getPlaceDetails(item.id, fields).then((result) => { if (nextElement) { nextElement.focus(); @@ -162,10 +167,11 @@ const GoogleAutocomplete: React.FC = ({ return; } - if (isLegacyUnavailableRef.current) { + if (isLegacyUnavailable) { placesApiServiceRef.current! .getSuggestions(input, types, componentRestrictions) - .then(setItems); + .then(setItems) + .catch(noop); return; } @@ -177,7 +183,7 @@ const GoogleAutocomplete: React.FC = ({ { input, types: types || ['geocode'], componentRestrictions }, (results, status) => { if (status === 'REQUEST_DENIED') { - isLegacyUnavailableRef.current = true; + isLegacyUnavailable = true; placesApiServiceRef.current! .getSuggestions(input, types, componentRestrictions) .then(setItems); @@ -190,7 +196,7 @@ const GoogleAutocomplete: React.FC = ({ ); }) .catch(() => { - isLegacyUnavailableRef.current = true; + isLegacyUnavailable = true; placesApiServiceRef.current! .getSuggestions(input, types, componentRestrictions) .then(setItems); From 9672f94ed0284b163fddcac8b2fe7ecb0a0a55f8 Mon Sep 17 00:00:00 2001 From: bc-maxy Date: Mon, 29 Jun 2026 14:41:37 +1000 Subject: [PATCH 13/21] fix(checkout): CHECKOUT-10026 Fix review comments --- .../GoogleAutocomplete.test.tsx | 9 +- .../googleAutocomplete/GoogleAutocomplete.tsx | 121 ++++++++++-------- .../GoogleAutocompleteService.test.ts | 39 ++---- .../GoogleAutocompleteService.ts | 39 +++--- .../placesApiGoogleAutocomplete/utils.test.ts | 26 ++++ .../placesApiGoogleAutocomplete/utils.ts | 19 ++- 6 files changed, 145 insertions(+), 108 deletions(-) diff --git a/packages/core/src/app/address/googleAutocomplete/GoogleAutocomplete.test.tsx b/packages/core/src/app/address/googleAutocomplete/GoogleAutocomplete.test.tsx index caba08a01f..39ace459bb 100644 --- a/packages/core/src/app/address/googleAutocomplete/GoogleAutocomplete.test.tsx +++ b/packages/core/src/app/address/googleAutocomplete/GoogleAutocomplete.test.tsx @@ -3,7 +3,10 @@ import React from 'react'; import { render, screen, waitFor } from '@bigcommerce/checkout/test-utils'; -import GoogleAutocomplete, { type GoogleAutocompleteProps } from './GoogleAutocomplete'; +import GoogleAutocomplete, { + type GoogleAutocompleteProps, + legacyAutocompleteState, +} from './GoogleAutocomplete'; import LegacyGoogleAutocompleteService from './GoogleAutocompleteService'; import { GoogleAutocompleteService as PlacesApiService } from './placesApiGoogleAutocomplete/GoogleAutocompleteService'; @@ -44,6 +47,10 @@ describe('GoogleAutocomplete', () => { let defaultProps: GoogleAutocompleteProps; beforeEach(() => { + // The component persists an "is legacy unavailable" flag at module scope so it + // survives unmount/remount. Reset it between tests to keep cases independent. + legacyAutocompleteState.isUnavailable = false; + mockGetPlacePredictions = jest.fn(); mockGetDetails = jest.fn(); mockGetSuggestions = jest.fn().mockResolvedValue(newApiSuggestions); diff --git a/packages/core/src/app/address/googleAutocomplete/GoogleAutocomplete.tsx b/packages/core/src/app/address/googleAutocomplete/GoogleAutocomplete.tsx index 2a4bf0a9f3..246e42cab1 100644 --- a/packages/core/src/app/address/googleAutocomplete/GoogleAutocomplete.tsx +++ b/packages/core/src/app/address/googleAutocomplete/GoogleAutocomplete.tsx @@ -1,13 +1,19 @@ +import { Autocomplete, type AutocompleteItem } from '@bigcommerce/checkout/ui'; import { noop } from 'lodash'; import React, { useEffect, useRef, useState } from 'react'; -import { Autocomplete, type AutocompleteItem } from '@bigcommerce/checkout/ui'; - import GoogleAutocompleteService from './GoogleAutocompleteService'; import { type GoogleAutocompleteOptionTypes } from './googleAutocompleteTypes'; import { GoogleAutocompleteService as PlacesApiGoogleAutocompleteService } from './placesApiGoogleAutocomplete/GoogleAutocompleteService'; import './GoogleAutocomplete.scss'; +declare global { + interface Window { + // Global hook the Google Maps JS SDK invokes when it processes an auth error. + gm_authFailure?: () => void; + } +} + export interface GoogleAutocompleteProps { initialValue?: string; componentRestrictions?: google.maps.places.ComponentRestrictions; @@ -37,7 +43,8 @@ const toAutocompleteItems = ( // unmount/remount when navigating between Shipping and Billing steps. // Once the legacy API fails for a given API key it won't recover mid-session, // so a single module-level flag is safe to share across all instances. -let isLegacyUnavailable = false; +// Held on an object (rather than a bare `let`) so tests can reset it between cases. +export const legacyAutocompleteState = { isUnavailable: false }; const GoogleAutocomplete: React.FC = ({ initialValue, @@ -58,6 +65,14 @@ const GoogleAutocomplete: React.FC = ({ const placesApiServiceRef = useRef(); const currentInputRef = useRef(''); + // Track the latest values so the long-lived gm_authFailure handler (installed once, + // below) always reads current props rather than the values captured at mount. + const typesRef = useRef(types); + const componentRestrictionsRef = useRef(componentRestrictions); + + typesRef.current = types; + componentRestrictionsRef.current = componentRestrictions; + if (!googleAutocompleteServiceRef.current) { googleAutocompleteServiceRef.current = new GoogleAutocompleteService(apiKey); } @@ -71,77 +86,73 @@ const GoogleAutocomplete: React.FC = ({ // In that case getPlacePredictions' callback never fires, so we detect the failure // here and immediately retry the current input against the new Places API. useEffect(() => { - const win = window as Window & { gm_authFailure?: () => void }; - const prev = win.gm_authFailure; + const prev = window.gm_authFailure; - win.gm_authFailure = () => { - win.gm_authFailure = prev; - isLegacyUnavailable = true; + window.gm_authFailure = () => { + window.gm_authFailure = prev; + legacyAutocompleteState.isUnavailable = true; if (currentInputRef.current) { placesApiServiceRef - .current!.getSuggestions(currentInputRef.current, types, componentRestrictions) + .current!.getSuggestions( + currentInputRef.current, + typesRef.current, + componentRestrictionsRef.current, + ) .then(setItems) .catch(noop); } }; return () => { - win.gm_authFailure = prev; + window.gm_authFailure = prev; }; }, []); - const onSelectHandler = (item: AutocompleteItem) => { - if (isLegacyUnavailable) { - placesApiServiceRef.current!.getPlaceDetails(item.id, fields).then((result) => { - if (nextElement) { - nextElement.focus(); - } + const finalizeSelection = ( + place: google.maps.places.PlaceResult | null, + item: AutocompleteItem, + ) => { + if (nextElement) { + nextElement.focus(); + } - onSelect(result, item); - }); + onSelect(place, item); + }; + + const selectViaNewApi = (item: AutocompleteItem) => + placesApiServiceRef + .current!.getPlaceDetails(item.id, fields) + .then((result) => finalizeSelection(result, item)) + .catch(noop); + + const onSelectHandler = (item: AutocompleteItem) => { + if (legacyAutocompleteState.isUnavailable) { + void selectViaNewApi(item); return; } - googleAutocompleteServiceRef.current! - .getPlacesServices() + googleAutocompleteServiceRef + .current!.getPlacesServices() .then((placesService) => { placesService.getDetails( { placeId: item.id, fields: fields || ['address_components', 'name'] }, (result, status) => { if (status === 'REQUEST_DENIED') { - isLegacyUnavailable = true; - placesApiServiceRef.current! - .getPlaceDetails(item.id, fields) - .then((newResult) => { - if (nextElement) { - nextElement.focus(); - } - - onSelect(newResult, item); - }); + legacyAutocompleteState.isUnavailable = true; + void selectViaNewApi(item); return; } - if (nextElement) { - nextElement.focus(); - } - - onSelect(result, item); + finalizeSelection(result, item); }, ); }) .catch(() => { - isLegacyUnavailable = true; - placesApiServiceRef.current!.getPlaceDetails(item.id, fields).then((result) => { - if (nextElement) { - nextElement.focus(); - } - - onSelect(result, item); - }); + legacyAutocompleteState.isUnavailable = true; + void selectViaNewApi(item); }); }; @@ -167,9 +178,9 @@ const GoogleAutocomplete: React.FC = ({ return; } - if (isLegacyUnavailable) { - placesApiServiceRef.current! - .getSuggestions(input, types, componentRestrictions) + if (legacyAutocompleteState.isUnavailable) { + placesApiServiceRef + .current!.getSuggestions(input, types, componentRestrictions) .then(setItems) .catch(noop); @@ -183,10 +194,11 @@ const GoogleAutocomplete: React.FC = ({ { input, types: types || ['geocode'], componentRestrictions }, (results, status) => { if (status === 'REQUEST_DENIED') { - isLegacyUnavailable = true; - placesApiServiceRef.current! - .getSuggestions(input, types, componentRestrictions) - .then(setItems); + legacyAutocompleteState.isUnavailable = true; + placesApiServiceRef + .current!.getSuggestions(input, types, componentRestrictions) + .then(setItems) + .catch(noop); return; } @@ -196,10 +208,11 @@ const GoogleAutocomplete: React.FC = ({ ); }) .catch(() => { - isLegacyUnavailable = true; - placesApiServiceRef.current! - .getSuggestions(input, types, componentRestrictions) - .then(setItems); + legacyAutocompleteState.isUnavailable = true; + placesApiServiceRef + .current!.getSuggestions(input, types, componentRestrictions) + .then(setItems) + .catch(noop); }); }; diff --git a/packages/core/src/app/address/googleAutocomplete/placesApiGoogleAutocomplete/GoogleAutocompleteService.test.ts b/packages/core/src/app/address/googleAutocomplete/placesApiGoogleAutocomplete/GoogleAutocompleteService.test.ts index c07701b3c7..e7f5af66e8 100644 --- a/packages/core/src/app/address/googleAutocomplete/placesApiGoogleAutocomplete/GoogleAutocompleteService.test.ts +++ b/packages/core/src/app/address/googleAutocomplete/placesApiGoogleAutocomplete/GoogleAutocompleteService.test.ts @@ -11,13 +11,10 @@ const mockSuggestions = [ }, ] as unknown as google.maps.places.AutocompleteSuggestion[]; -const mockSessionToken = {} as google.maps.places.AutocompleteSessionToken; - const mockPlacesLibrary = { AutocompleteSuggestion: { fetchAutocompleteSuggestions: jest.fn().mockResolvedValue({ suggestions: mockSuggestions }), }, - AutocompleteSessionToken: jest.fn().mockImplementation(() => mockSessionToken), } as unknown as google.maps.PlacesLibrary; const mockScriptLoader = { @@ -89,23 +86,6 @@ describe('GoogleAutocompleteService', () => { mockPlacesLibrary.AutocompleteSuggestion.fetchAutocompleteSuggestions, ).toHaveBeenCalledWith(expect.objectContaining({ includedRegionCodes: undefined })); }); - - it('creates a session token on the first call and passes it to the API', async () => { - await service.getSuggestions('123 Main', ['address']); - - expect(mockPlacesLibrary.AutocompleteSessionToken).toHaveBeenCalledTimes(1); - expect( - mockPlacesLibrary.AutocompleteSuggestion.fetchAutocompleteSuggestions, - ).toHaveBeenCalledWith(expect.objectContaining({ sessionToken: mockSessionToken })); - }); - - it('reuses the same session token across multiple calls', async () => { - await service.getSuggestions('1', ['address']); - await service.getSuggestions('12', ['address']); - await service.getSuggestions('123', ['address']); - - expect(mockPlacesLibrary.AutocompleteSessionToken).toHaveBeenCalledTimes(1); - }); }); describe('#getPlaceDetails()', () => { @@ -120,6 +100,17 @@ describe('GoogleAutocompleteService', () => { ); }); + it('translates legacy snake_case field names into a valid REST field mask', async () => { + await service.getPlaceDetails('place-1', ['address_components', 'name']); + + expect(global.fetch).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + headers: { 'X-Goog-FieldMask': 'addressComponents,displayName' }, + }), + ); + }); + it('uses default fields when none provided', async () => { await service.getPlaceDetails('place-1'); @@ -146,14 +137,6 @@ describe('GoogleAutocompleteService', () => { expect(result.name).toBe('123 Main St'); }); - it('resets the session token after getPlaceDetails so the next session gets a fresh token', async () => { - await service.getSuggestions('123 Main', ['address']); - await service.getPlaceDetails('place-1', ['addressComponents']); - await service.getSuggestions('456 Oak', ['address']); - - expect(mockPlacesLibrary.AutocompleteSessionToken).toHaveBeenCalledTimes(2); - }); - it('throws when the REST API returns a non-ok status', async () => { (global.fetch as jest.Mock).mockResolvedValueOnce({ ok: false, diff --git a/packages/core/src/app/address/googleAutocomplete/placesApiGoogleAutocomplete/GoogleAutocompleteService.ts b/packages/core/src/app/address/googleAutocomplete/placesApiGoogleAutocomplete/GoogleAutocompleteService.ts index ba6ca7dadd..c3e2f6183a 100644 --- a/packages/core/src/app/address/googleAutocomplete/placesApiGoogleAutocomplete/GoogleAutocompleteService.ts +++ b/packages/core/src/app/address/googleAutocomplete/placesApiGoogleAutocomplete/GoogleAutocompleteService.ts @@ -4,7 +4,12 @@ import type { GoogleAutocompleteOptionTypes } from '../googleAutocompleteTypes'; import { getGoogleAutocompleteScriptLoader } from './getGoogleAutocompleteScriptLoader'; import type { GoogleAutocompleteScriptLoader } from './GoogleAutocompleteScriptLoader'; -import { mapToAutocompleteItems, mapToIncludedPrimaryTypes } from './utils'; +import { + mapToAutocompleteItems, + mapToGeocoderAddressComponent, + mapToIncludedPrimaryTypes, + mapToPlaceDetailsFieldMask, +} from './utils'; interface PlacesRestResponse { addressComponents?: Array<{ @@ -16,8 +21,6 @@ interface PlacesRestResponse { } export class GoogleAutocompleteService { - private _sessionToken?: google.maps.places.AutocompleteSessionToken; - constructor( private _apiKey: string, private _scriptLoader: GoogleAutocompleteScriptLoader = getGoogleAutocompleteScriptLoader(), @@ -25,26 +28,18 @@ export class GoogleAutocompleteService { async getSuggestions( input: string, - types: string[] | undefined, + types: GoogleAutocompleteOptionTypes[] | undefined, componentRestrictions?: google.maps.places.ComponentRestrictions, ): Promise { - const { AutocompleteSuggestion, AutocompleteSessionToken } = - await this._scriptLoader.loadPlacesLibrary(this._apiKey); - - if (!this._sessionToken) { - this._sessionToken = new AutocompleteSessionToken(); - } + const { AutocompleteSuggestion } = await this._scriptLoader.loadPlacesLibrary(this._apiKey); const includedRegionCodes = this.convertToRegionCodes(componentRestrictions?.country); - const includedPrimaryTypes = mapToIncludedPrimaryTypes( - (types ?? ['geocode']) as GoogleAutocompleteOptionTypes[], - ); + const includedPrimaryTypes = mapToIncludedPrimaryTypes(types ?? ['geocode']); const { suggestions } = await AutocompleteSuggestion.fetchAutocompleteSuggestions({ input, includedPrimaryTypes, includedRegionCodes, - sessionToken: this._sessionToken, }); return mapToAutocompleteItems(suggestions); @@ -54,16 +49,16 @@ export class GoogleAutocompleteService { placeId: string, fields?: string[], ): Promise { - // Consume and reset the session token - this._sessionToken = undefined; - // Place.fetchFields silently does nothing when Maps JS was loaded via libraries=places // (ApiTargetBlockedMapError corrupts the SDK transport). Use the Places REST API // directly — it is completely independent of JS SDK auth state. - const requestedFields = fields?.length ? fields : ['addressComponents', 'displayName']; + // + // Callers pass legacy (snake_case) field names since the same `fields` prop also + // feeds the legacy getDetails API; translate them to the new REST field-mask names. + const fieldMask = mapToPlaceDetailsFieldMask(fields).join(','); const response = await fetch( `https://places.googleapis.com/v1/places/${encodeURIComponent(placeId)}?key=${this._apiKey}`, - { headers: { 'X-Goog-FieldMask': requestedFields.join(',') } }, + { headers: { 'X-Goog-FieldMask': fieldMask } }, ); if (!response.ok) { @@ -73,11 +68,7 @@ export class GoogleAutocompleteService { const data = (await response.json()) as PlacesRestResponse; return { - address_components: data.addressComponents?.map((c) => ({ - long_name: c.longText ?? '', - short_name: c.shortText ?? '', - types: c.types ?? [], - })), + address_components: data.addressComponents?.map(mapToGeocoderAddressComponent), name: data.displayName?.text ?? '', }; } diff --git a/packages/core/src/app/address/googleAutocomplete/placesApiGoogleAutocomplete/utils.test.ts b/packages/core/src/app/address/googleAutocomplete/placesApiGoogleAutocomplete/utils.test.ts index e9bf9fcc5d..c99d851543 100644 --- a/packages/core/src/app/address/googleAutocomplete/placesApiGoogleAutocomplete/utils.test.ts +++ b/packages/core/src/app/address/googleAutocomplete/placesApiGoogleAutocomplete/utils.test.ts @@ -2,8 +2,34 @@ import { mapToAutocompleteItems, mapToGeocoderAddressComponent, mapToIncludedPrimaryTypes, + mapToPlaceDetailsFieldMask, } from './utils'; +describe('mapToPlaceDetailsFieldMask', () => { + it('maps legacy snake_case field names to new-API camelCase names', () => { + expect(mapToPlaceDetailsFieldMask(['address_components', 'name'])).toEqual([ + 'addressComponents', + 'displayName', + ]); + }); + + it('passes through new-API field names unchanged', () => { + expect(mapToPlaceDetailsFieldMask(['addressComponents', 'displayName'])).toEqual([ + 'addressComponents', + 'displayName', + ]); + }); + + it('de-duplicates fields that map to the same name', () => { + expect(mapToPlaceDetailsFieldMask(['name', 'displayName'])).toEqual(['displayName']); + }); + + it('defaults to address components and display name when none provided', () => { + expect(mapToPlaceDetailsFieldMask()).toEqual(['addressComponents', 'displayName']); + expect(mapToPlaceDetailsFieldMask([])).toEqual(['addressComponents', 'displayName']); + }); +}); + describe('mapToIncludedPrimaryTypes', () => { it('maps "address" to "street_address"', () => { expect(mapToIncludedPrimaryTypes(['address'])).toEqual(['street_address']); diff --git a/packages/core/src/app/address/googleAutocomplete/placesApiGoogleAutocomplete/utils.ts b/packages/core/src/app/address/googleAutocomplete/placesApiGoogleAutocomplete/utils.ts index 109717e099..fff3f631ed 100644 --- a/packages/core/src/app/address/googleAutocomplete/placesApiGoogleAutocomplete/utils.ts +++ b/packages/core/src/app/address/googleAutocomplete/placesApiGoogleAutocomplete/utils.ts @@ -48,8 +48,25 @@ export const mapToAutocompleteItems = ( ]; }); +/** + * Maps legacy Places API field names (snake_case, used by the legacy getDetails + * service) to the new Places REST API field-mask names (camelCase). Names that + * are already new-API names — or any unrecognised name — pass through unchanged, + * and the result is de-duplicated. Defaults to address + name when none given. + */ +const LEGACY_FIELD_MAP: Record = { + address_components: 'addressComponents', + name: 'displayName', +}; + +export const mapToPlaceDetailsFieldMask = (fields?: string[]): string[] => { + const source = fields?.length ? fields : ['address_components', 'name']; + + return Array.from(new Set(source.map((field) => LEGACY_FIELD_MAP[field] ?? field))); +}; + export const mapToGeocoderAddressComponent = ( - c: google.maps.places.AddressComponent, + c: { longText?: string | null; shortText?: string | null; types?: string[] | null }, ): google.maps.GeocoderAddressComponent => ({ long_name: c.longText ?? '', short_name: c.shortText ?? '', From 9796c110316aff26798247ba354511a4b085015d Mon Sep 17 00:00:00 2001 From: bc-maxy Date: Mon, 29 Jun 2026 19:06:37 +1000 Subject: [PATCH 14/21] fix(checkout): CHECKOUT-10026 Clean up the implementation --- .../GoogleAutocomplete.test.tsx | 48 +++++------- .../googleAutocomplete/GoogleAutocomplete.tsx | 31 +++----- .../NewGooglePlacesApiScriptLoader.ts} | 6 +- .../NewGooglePlacesApiService.test.ts} | 30 ++++---- .../NewGooglePlacesApiService.ts} | 39 +++++----- .../getNewGooglePlacesApiScriptLoader.ts | 11 +++ .../newGooglePlacesApi/index.ts | 1 + .../utils.test.ts | 53 +++++++------ .../newGooglePlacesApi/utils.ts | 63 ++++++++++++++++ .../getGoogleAutocompleteScriptLoader.ts | 11 --- .../placesApiGoogleAutocomplete/utils.ts | 74 ------------------- packages/core/tsconfig.spec.json | 2 +- 12 files changed, 176 insertions(+), 193 deletions(-) rename packages/core/src/app/address/googleAutocomplete/{placesApiGoogleAutocomplete/GoogleAutocompleteScriptLoader.ts => newGooglePlacesApi/NewGooglePlacesApiScriptLoader.ts} (90%) rename packages/core/src/app/address/googleAutocomplete/{placesApiGoogleAutocomplete/GoogleAutocompleteService.test.ts => newGooglePlacesApi/NewGooglePlacesApiService.test.ts} (87%) rename packages/core/src/app/address/googleAutocomplete/{placesApiGoogleAutocomplete/GoogleAutocompleteService.ts => newGooglePlacesApi/NewGooglePlacesApiService.ts} (59%) create mode 100644 packages/core/src/app/address/googleAutocomplete/newGooglePlacesApi/getNewGooglePlacesApiScriptLoader.ts create mode 100644 packages/core/src/app/address/googleAutocomplete/newGooglePlacesApi/index.ts rename packages/core/src/app/address/googleAutocomplete/{placesApiGoogleAutocomplete => newGooglePlacesApi}/utils.test.ts (59%) create mode 100644 packages/core/src/app/address/googleAutocomplete/newGooglePlacesApi/utils.ts delete mode 100644 packages/core/src/app/address/googleAutocomplete/placesApiGoogleAutocomplete/getGoogleAutocompleteScriptLoader.ts delete mode 100644 packages/core/src/app/address/googleAutocomplete/placesApiGoogleAutocomplete/utils.ts diff --git a/packages/core/src/app/address/googleAutocomplete/GoogleAutocomplete.test.tsx b/packages/core/src/app/address/googleAutocomplete/GoogleAutocomplete.test.tsx index 39ace459bb..bb4fec9cc1 100644 --- a/packages/core/src/app/address/googleAutocomplete/GoogleAutocomplete.test.tsx +++ b/packages/core/src/app/address/googleAutocomplete/GoogleAutocomplete.test.tsx @@ -8,7 +8,7 @@ import GoogleAutocomplete, { legacyAutocompleteState, } from './GoogleAutocomplete'; import LegacyGoogleAutocompleteService from './GoogleAutocompleteService'; -import { GoogleAutocompleteService as PlacesApiService } from './placesApiGoogleAutocomplete/GoogleAutocompleteService'; +import { NewGooglePlacesApiService } from './newGooglePlacesApi/NewGooglePlacesApiService'; jest.mock('./GoogleAutocompleteService'); jest.mock('./placesApiGoogleAutocomplete/GoogleAutocompleteService'); @@ -16,7 +16,9 @@ jest.mock('./placesApiGoogleAutocomplete/GoogleAutocompleteService'); const MockLegacyService = LegacyGoogleAutocompleteService as jest.MockedClass< typeof LegacyGoogleAutocompleteService >; -const MockPlacesApiService = PlacesApiService as jest.MockedClass; +const MockPlacesApiService = NewGooglePlacesApiService as jest.MockedClass< + typeof NewGooglePlacesApiService +>; const legacySuggestions = [ { @@ -25,14 +27,16 @@ const legacySuggestions = [ matched_substrings: [], place_id: 'legacy-place-1', }, -] as unknown as google.maps.places.AutocompletePrediction[]; +]; const legacyPlaceResult = { name: '123 Legacy St', address_components: [], } as google.maps.places.PlaceResult; -const newApiSuggestions = [{ id: 'new-place-1', label: '123 New API Ave', value: '123 New API Ave' }]; +const newApiSuggestions = [ + { id: 'new-place-1', label: '123 New API Ave', value: '123 New API Ave' }, +]; const newApiPlaceResult = { name: '123 New API Ave', @@ -47,8 +51,6 @@ describe('GoogleAutocomplete', () => { let defaultProps: GoogleAutocompleteProps; beforeEach(() => { - // The component persists an "is legacy unavailable" flag at module scope so it - // survives unmount/remount. Reset it between tests to keep cases independent. legacyAutocompleteState.isUnavailable = false; mockGetPlacePredictions = jest.fn(); @@ -62,9 +64,7 @@ describe('GoogleAutocomplete', () => { getAutocompleteService: jest .fn() .mockResolvedValue({ getPlacePredictions: mockGetPlacePredictions }), - getPlacesServices: jest - .fn() - .mockResolvedValue({ getDetails: mockGetDetails }), + getPlacesServices: jest.fn().mockResolvedValue({ getDetails: mockGetDetails }), }) as unknown as LegacyGoogleAutocompleteService, ); @@ -73,7 +73,7 @@ describe('GoogleAutocomplete', () => { ({ getSuggestions: mockGetSuggestions, getPlaceDetails: mockGetPlaceDetails, - }) as unknown as PlacesApiService, + }) as unknown as NewGooglePlacesApiService, ); defaultProps = { @@ -86,9 +86,7 @@ describe('GoogleAutocomplete', () => { describe('legacy API available (existing customers)', () => { beforeEach(() => { - mockGetPlacePredictions.mockImplementation((_req, cb) => - cb(legacySuggestions, 'OK'), - ); + mockGetPlacePredictions.mockImplementation((_req, cb) => cb(legacySuggestions, 'OK')); mockGetDetails.mockImplementation((_req, cb) => cb(legacyPlaceResult, 'OK')); }); @@ -97,9 +95,7 @@ describe('GoogleAutocomplete', () => { await userEvent.type(screen.getByRole('textbox'), '123'); - await waitFor(() => - expect(screen.getByText('123 Legacy St, New York')).toBeInTheDocument(), - ); + await screen.findByText('123 Legacy St, New York'); expect(mockGetSuggestions).not.toHaveBeenCalled(); }); @@ -107,9 +103,7 @@ describe('GoogleAutocomplete', () => { render(); await userEvent.type(screen.getByRole('textbox'), '123'); - await waitFor(() => - expect(screen.getByText('123 Legacy St, New York')).toBeInTheDocument(), - ); + await screen.findByText('123 Legacy St, New York'); await userEvent.click(screen.getByText('123 Legacy St, New York')); await waitFor(() => @@ -124,9 +118,7 @@ describe('GoogleAutocomplete', () => { describe('legacy API unavailable (new customers)', () => { beforeEach(() => { - mockGetPlacePredictions.mockImplementation((_req, cb) => - cb(null, 'REQUEST_DENIED'), - ); + mockGetPlacePredictions.mockImplementation((_req, cb) => cb(null, 'REQUEST_DENIED')); mockGetDetails.mockImplementation((_req, cb) => cb(null, 'REQUEST_DENIED')); }); @@ -135,9 +127,7 @@ describe('GoogleAutocomplete', () => { await userEvent.type(screen.getByRole('textbox'), '123'); - await waitFor(() => - expect(screen.getByText('123 New API Ave')).toBeInTheDocument(), - ); + await screen.findByText('123 New API Ave'); expect(mockGetSuggestions).toHaveBeenCalledWith( expect.stringContaining('123'), undefined, @@ -159,17 +149,13 @@ describe('GoogleAutocomplete', () => { it('falls back to the new API for place details on REQUEST_DENIED', async () => { // Return OK for suggestions so the dropdown has an item to click, // but REQUEST_DENIED for getDetails (set in beforeEach). - mockGetPlacePredictions.mockImplementation((_req, cb) => - cb(legacySuggestions, 'OK'), - ); + mockGetPlacePredictions.mockImplementation((_req, cb) => cb(legacySuggestions, 'OK')); render(); // Type a single character so only one getPlacePredictions call fires await userEvent.type(screen.getByRole('textbox'), '1'); - await waitFor(() => - expect(screen.getByText('123 Legacy St, New York')).toBeInTheDocument(), - ); + await screen.findByText('123 Legacy St, New York'); await userEvent.click(screen.getByText('123 Legacy St, New York')); await waitFor(() => diff --git a/packages/core/src/app/address/googleAutocomplete/GoogleAutocomplete.tsx b/packages/core/src/app/address/googleAutocomplete/GoogleAutocomplete.tsx index 246e42cab1..95114807da 100644 --- a/packages/core/src/app/address/googleAutocomplete/GoogleAutocomplete.tsx +++ b/packages/core/src/app/address/googleAutocomplete/GoogleAutocomplete.tsx @@ -1,10 +1,11 @@ -import { Autocomplete, type AutocompleteItem } from '@bigcommerce/checkout/ui'; import { noop } from 'lodash'; import React, { useEffect, useRef, useState } from 'react'; +import { Autocomplete, type AutocompleteItem } from '@bigcommerce/checkout/ui'; + import GoogleAutocompleteService from './GoogleAutocompleteService'; import { type GoogleAutocompleteOptionTypes } from './googleAutocompleteTypes'; -import { GoogleAutocompleteService as PlacesApiGoogleAutocompleteService } from './placesApiGoogleAutocomplete/GoogleAutocompleteService'; +import { NewGooglePlacesApiService } from './newGooglePlacesApi'; import './GoogleAutocomplete.scss'; declare global { @@ -39,11 +40,6 @@ const toAutocompleteItems = ( })); }; -// Persisted at module scope so the legacy-unavailable signal survives -// unmount/remount when navigating between Shipping and Billing steps. -// Once the legacy API fails for a given API key it won't recover mid-session, -// so a single module-level flag is safe to share across all instances. -// Held on an object (rather than a bare `let`) so tests can reset it between cases. export const legacyAutocompleteState = { isUnavailable: false }; const GoogleAutocomplete: React.FC = ({ @@ -62,7 +58,7 @@ const GoogleAutocomplete: React.FC = ({ const [items, setItems] = useState([]); const [autoComplete, setAutoComplete] = useState('off'); const googleAutocompleteServiceRef = useRef(); - const placesApiServiceRef = useRef(); + const newGooglePlacesApiServiceRef = useRef(); const currentInputRef = useRef(''); // Track the latest values so the long-lived gm_authFailure handler (installed once, @@ -77,14 +73,11 @@ const GoogleAutocomplete: React.FC = ({ googleAutocompleteServiceRef.current = new GoogleAutocompleteService(apiKey); } - if (!placesApiServiceRef.current) { - placesApiServiceRef.current = new PlacesApiGoogleAutocompleteService(apiKey); + if (!newGooglePlacesApiServiceRef.current) { + newGooglePlacesApiServiceRef.current = new NewGooglePlacesApiService(apiKey); } - // gm_authFailure fires when the Maps JS SDK processes an auth error asynchronously - // (e.g. ApiTargetBlockedMapError for keys without legacy Places API access). - // In that case getPlacePredictions' callback never fires, so we detect the failure - // here and immediately retry the current input against the new Places API. + // this is to catch errors in legacy Autocomplete SDK useEffect(() => { const prev = window.gm_authFailure; @@ -93,7 +86,7 @@ const GoogleAutocomplete: React.FC = ({ legacyAutocompleteState.isUnavailable = true; if (currentInputRef.current) { - placesApiServiceRef + newGooglePlacesApiServiceRef .current!.getSuggestions( currentInputRef.current, typesRef.current, @@ -121,7 +114,7 @@ const GoogleAutocomplete: React.FC = ({ }; const selectViaNewApi = (item: AutocompleteItem) => - placesApiServiceRef + newGooglePlacesApiServiceRef .current!.getPlaceDetails(item.id, fields) .then((result) => finalizeSelection(result, item)) .catch(noop); @@ -179,7 +172,7 @@ const GoogleAutocomplete: React.FC = ({ } if (legacyAutocompleteState.isUnavailable) { - placesApiServiceRef + newGooglePlacesApiServiceRef .current!.getSuggestions(input, types, componentRestrictions) .then(setItems) .catch(noop); @@ -195,7 +188,7 @@ const GoogleAutocomplete: React.FC = ({ (results, status) => { if (status === 'REQUEST_DENIED') { legacyAutocompleteState.isUnavailable = true; - placesApiServiceRef + newGooglePlacesApiServiceRef .current!.getSuggestions(input, types, componentRestrictions) .then(setItems) .catch(noop); @@ -209,7 +202,7 @@ const GoogleAutocomplete: React.FC = ({ }) .catch(() => { legacyAutocompleteState.isUnavailable = true; - placesApiServiceRef + newGooglePlacesApiServiceRef .current!.getSuggestions(input, types, componentRestrictions) .then(setItems) .catch(noop); diff --git a/packages/core/src/app/address/googleAutocomplete/placesApiGoogleAutocomplete/GoogleAutocompleteScriptLoader.ts b/packages/core/src/app/address/googleAutocomplete/newGooglePlacesApi/NewGooglePlacesApiScriptLoader.ts similarity index 90% rename from packages/core/src/app/address/googleAutocomplete/placesApiGoogleAutocomplete/GoogleAutocompleteScriptLoader.ts rename to packages/core/src/app/address/googleAutocomplete/newGooglePlacesApi/NewGooglePlacesApiScriptLoader.ts index 5d2965e8aa..2a94c4172c 100644 --- a/packages/core/src/app/address/googleAutocomplete/placesApiGoogleAutocomplete/GoogleAutocompleteScriptLoader.ts +++ b/packages/core/src/app/address/googleAutocomplete/newGooglePlacesApi/NewGooglePlacesApiScriptLoader.ts @@ -1,6 +1,6 @@ import { getScriptLoader, type ScriptLoader } from '@bigcommerce/script-loader'; -export class GoogleAutocompleteScriptLoader { +export class NewGooglePlacesApiScriptLoader { private _scriptLoader: ScriptLoader = getScriptLoader(); private _placesPromise?: Promise; @@ -12,14 +12,14 @@ export class GoogleAutocompleteScriptLoader { // If Maps JS is already on the page (e.g. loaded by the legacy AutocompleteService), // skip injecting a second script tag — loading the SDK twice corrupts its internal // state and causes fetchAutocompleteSuggestions to throw. Go straight to importLibrary. - const mapsReady = + const getMapsJsReady = typeof google !== 'undefined' && google?.maps ? Promise.resolve() : this._scriptLoader.loadScript( `//maps.googleapis.com/maps/api/js?${['language=en', `key=${apiKey}`, 'loading=async'].join('&')}`, ); - this._placesPromise = mapsReady + this._placesPromise = getMapsJsReady .then(() => google.maps.importLibrary('places') as Promise) .catch((e) => { this._placesPromise = undefined; diff --git a/packages/core/src/app/address/googleAutocomplete/placesApiGoogleAutocomplete/GoogleAutocompleteService.test.ts b/packages/core/src/app/address/googleAutocomplete/newGooglePlacesApi/NewGooglePlacesApiService.test.ts similarity index 87% rename from packages/core/src/app/address/googleAutocomplete/placesApiGoogleAutocomplete/GoogleAutocompleteService.test.ts rename to packages/core/src/app/address/googleAutocomplete/newGooglePlacesApi/NewGooglePlacesApiService.test.ts index e7f5af66e8..e100307929 100644 --- a/packages/core/src/app/address/googleAutocomplete/placesApiGoogleAutocomplete/GoogleAutocompleteService.test.ts +++ b/packages/core/src/app/address/googleAutocomplete/newGooglePlacesApi/NewGooglePlacesApiService.test.ts @@ -1,5 +1,5 @@ -import { type GoogleAutocompleteScriptLoader } from './GoogleAutocompleteScriptLoader'; -import { GoogleAutocompleteService } from './GoogleAutocompleteService'; +import { type NewGooglePlacesApiScriptLoader } from './NewGooglePlacesApiScriptLoader'; +import { NewGooglePlacesApiService } from './NewGooglePlacesApiService'; const mockSuggestions = [ { @@ -9,17 +9,17 @@ const mockSuggestions = [ mainText: { text: '123 Main St' }, }, }, -] as unknown as google.maps.places.AutocompleteSuggestion[]; +]; const mockPlacesLibrary = { AutocompleteSuggestion: { fetchAutocompleteSuggestions: jest.fn().mockResolvedValue({ suggestions: mockSuggestions }), }, -} as unknown as google.maps.PlacesLibrary; +}; const mockScriptLoader = { loadPlacesLibrary: jest.fn().mockResolvedValue(mockPlacesLibrary), -} as unknown as GoogleAutocompleteScriptLoader; +} as unknown as NewGooglePlacesApiScriptLoader; const mockPlaceDetailsResponse = { addressComponents: [ @@ -29,20 +29,20 @@ const mockPlaceDetailsResponse = { displayName: { text: '123 Main St' }, }; -describe('GoogleAutocompleteService', () => { - let service: GoogleAutocompleteService; +describe('NewGooglePlacesApiService', () => { + let service: NewGooglePlacesApiService; beforeEach(() => { jest.clearAllMocks(); - service = new GoogleAutocompleteService('test-api-key', mockScriptLoader); + service = new NewGooglePlacesApiService('test-api-key', mockScriptLoader); global.fetch = jest.fn().mockResolvedValue({ ok: true, json: jest.fn().mockResolvedValue(mockPlaceDetailsResponse), - } as unknown as Response); + }); }); - describe('#getSuggestions()', () => { + describe('getSuggestions', () => { it('returns suggestions mapped to AutocompleteItems', async () => { const result = await service.getSuggestions('123 Main', ['address']); @@ -88,7 +88,7 @@ describe('GoogleAutocompleteService', () => { }); }); - describe('#getPlaceDetails()', () => { + describe('getPlaceDetails', () => { it('fetches from the Places REST API with the correct URL and field mask', async () => { await service.getPlaceDetails('place-1', ['addressComponents', 'displayName']); @@ -126,7 +126,11 @@ describe('GoogleAutocompleteService', () => { const result = await service.getPlaceDetails('place-1'); expect(result.address_components).toEqual([ - { long_name: 'New South Wales', short_name: 'NSW', types: ['administrative_area_level_1'] }, + { + long_name: 'New South Wales', + short_name: 'NSW', + types: ['administrative_area_level_1'], + }, { long_name: 'Australia', short_name: 'AU', types: ['country'] }, ]); }); @@ -141,7 +145,7 @@ describe('GoogleAutocompleteService', () => { (global.fetch as jest.Mock).mockResolvedValueOnce({ ok: false, status: 403, - } as unknown as Response); + }); await expect(service.getPlaceDetails('place-1')).rejects.toThrow( 'Places API request failed with status 403', diff --git a/packages/core/src/app/address/googleAutocomplete/placesApiGoogleAutocomplete/GoogleAutocompleteService.ts b/packages/core/src/app/address/googleAutocomplete/newGooglePlacesApi/NewGooglePlacesApiService.ts similarity index 59% rename from packages/core/src/app/address/googleAutocomplete/placesApiGoogleAutocomplete/GoogleAutocompleteService.ts rename to packages/core/src/app/address/googleAutocomplete/newGooglePlacesApi/NewGooglePlacesApiService.ts index c3e2f6183a..f4d318b5f8 100644 --- a/packages/core/src/app/address/googleAutocomplete/placesApiGoogleAutocomplete/GoogleAutocompleteService.ts +++ b/packages/core/src/app/address/googleAutocomplete/newGooglePlacesApi/NewGooglePlacesApiService.ts @@ -2,13 +2,13 @@ import type { AutocompleteItem } from '@bigcommerce/checkout/ui'; import type { GoogleAutocompleteOptionTypes } from '../googleAutocompleteTypes'; -import { getGoogleAutocompleteScriptLoader } from './getGoogleAutocompleteScriptLoader'; -import type { GoogleAutocompleteScriptLoader } from './GoogleAutocompleteScriptLoader'; +import { getNewGooglePlacesApiScriptLoader } from './getNewGooglePlacesApiScriptLoader'; +import type { NewGooglePlacesApiScriptLoader } from './NewGooglePlacesApiScriptLoader'; import { - mapToAutocompleteItems, - mapToGeocoderAddressComponent, - mapToIncludedPrimaryTypes, - mapToPlaceDetailsFieldMask, + mapLegacyToNewIncludedPrimaryTypes, + mapLegacyToNewPlaceDetailsFieldMask, + mapNewToOldGeocoderAddressComponent, + mapSuggestionsToAutocompleteItems, } from './utils'; interface PlacesRestResponse { @@ -20,10 +20,10 @@ interface PlacesRestResponse { displayName?: { text: string }; } -export class GoogleAutocompleteService { +export class NewGooglePlacesApiService { constructor( private _apiKey: string, - private _scriptLoader: GoogleAutocompleteScriptLoader = getGoogleAutocompleteScriptLoader(), + private _scriptLoader: NewGooglePlacesApiScriptLoader = getNewGooglePlacesApiScriptLoader(), ) {} async getSuggestions( @@ -34,7 +34,7 @@ export class GoogleAutocompleteService { const { AutocompleteSuggestion } = await this._scriptLoader.loadPlacesLibrary(this._apiKey); const includedRegionCodes = this.convertToRegionCodes(componentRestrictions?.country); - const includedPrimaryTypes = mapToIncludedPrimaryTypes(types ?? ['geocode']); + const includedPrimaryTypes = mapLegacyToNewIncludedPrimaryTypes(types ?? ['geocode']); const { suggestions } = await AutocompleteSuggestion.fetchAutocompleteSuggestions({ input, @@ -42,20 +42,19 @@ export class GoogleAutocompleteService { includedRegionCodes, }); - return mapToAutocompleteItems(suggestions); + return mapSuggestionsToAutocompleteItems(suggestions); } + /* This method uses Places REST API instead of the SDK. + * Place.fetchFields silently does nothing when Maps JS auth fails. + * Which is the case when we try legacy Autocomplete first. + */ async getPlaceDetails( placeId: string, fields?: string[], ): Promise { - // Place.fetchFields silently does nothing when Maps JS was loaded via libraries=places - // (ApiTargetBlockedMapError corrupts the SDK transport). Use the Places REST API - // directly — it is completely independent of JS SDK auth state. - // - // Callers pass legacy (snake_case) field names since the same `fields` prop also - // feeds the legacy getDetails API; translate them to the new REST field-mask names. - const fieldMask = mapToPlaceDetailsFieldMask(fields).join(','); + const fieldMask = mapLegacyToNewPlaceDetailsFieldMask(fields).join(','); + const response = await fetch( `https://places.googleapis.com/v1/places/${encodeURIComponent(placeId)}?key=${this._apiKey}`, { headers: { 'X-Goog-FieldMask': fieldMask } }, @@ -68,7 +67,7 @@ export class GoogleAutocompleteService { const data = (await response.json()) as PlacesRestResponse; return { - address_components: data.addressComponents?.map(mapToGeocoderAddressComponent), + address_components: data.addressComponents?.map(mapNewToOldGeocoderAddressComponent), name: data.displayName?.text ?? '', }; } @@ -76,7 +75,9 @@ export class GoogleAutocompleteService { private convertToRegionCodes( country: string | string[] | null | undefined, ): string[] | undefined { - if (!country) return undefined; + if (!country) { + return undefined; + } return Array.isArray(country) ? country : [country]; } diff --git a/packages/core/src/app/address/googleAutocomplete/newGooglePlacesApi/getNewGooglePlacesApiScriptLoader.ts b/packages/core/src/app/address/googleAutocomplete/newGooglePlacesApi/getNewGooglePlacesApiScriptLoader.ts new file mode 100644 index 0000000000..706e70e9ef --- /dev/null +++ b/packages/core/src/app/address/googleAutocomplete/newGooglePlacesApi/getNewGooglePlacesApiScriptLoader.ts @@ -0,0 +1,11 @@ +import { NewGooglePlacesApiScriptLoader } from './NewGooglePlacesApiScriptLoader'; + +let instance: NewGooglePlacesApiScriptLoader; + +export function getNewGooglePlacesApiScriptLoader(): NewGooglePlacesApiScriptLoader { + if (!instance) { + instance = new NewGooglePlacesApiScriptLoader(); + } + + return instance; +} diff --git a/packages/core/src/app/address/googleAutocomplete/newGooglePlacesApi/index.ts b/packages/core/src/app/address/googleAutocomplete/newGooglePlacesApi/index.ts new file mode 100644 index 0000000000..8f313838af --- /dev/null +++ b/packages/core/src/app/address/googleAutocomplete/newGooglePlacesApi/index.ts @@ -0,0 +1 @@ +export { NewGooglePlacesApiService } from './NewGooglePlacesApiService'; diff --git a/packages/core/src/app/address/googleAutocomplete/placesApiGoogleAutocomplete/utils.test.ts b/packages/core/src/app/address/googleAutocomplete/newGooglePlacesApi/utils.test.ts similarity index 59% rename from packages/core/src/app/address/googleAutocomplete/placesApiGoogleAutocomplete/utils.test.ts rename to packages/core/src/app/address/googleAutocomplete/newGooglePlacesApi/utils.test.ts index c99d851543..bbc4e3ab5b 100644 --- a/packages/core/src/app/address/googleAutocomplete/placesApiGoogleAutocomplete/utils.test.ts +++ b/packages/core/src/app/address/googleAutocomplete/newGooglePlacesApi/utils.test.ts @@ -1,58 +1,65 @@ import { - mapToAutocompleteItems, - mapToGeocoderAddressComponent, - mapToIncludedPrimaryTypes, - mapToPlaceDetailsFieldMask, + mapLegacyToNewIncludedPrimaryTypes, + mapLegacyToNewPlaceDetailsFieldMask, + mapNewToOldGeocoderAddressComponent, + mapSuggestionsToAutocompleteItems, } from './utils'; -describe('mapToPlaceDetailsFieldMask', () => { +describe('mapLegacyToNewPlaceDetailsFieldMask', () => { it('maps legacy snake_case field names to new-API camelCase names', () => { - expect(mapToPlaceDetailsFieldMask(['address_components', 'name'])).toEqual([ + expect(mapLegacyToNewPlaceDetailsFieldMask(['address_components', 'name'])).toEqual([ 'addressComponents', 'displayName', ]); }); it('passes through new-API field names unchanged', () => { - expect(mapToPlaceDetailsFieldMask(['addressComponents', 'displayName'])).toEqual([ + expect(mapLegacyToNewPlaceDetailsFieldMask(['addressComponents', 'displayName'])).toEqual([ 'addressComponents', 'displayName', ]); }); it('de-duplicates fields that map to the same name', () => { - expect(mapToPlaceDetailsFieldMask(['name', 'displayName'])).toEqual(['displayName']); + expect(mapLegacyToNewPlaceDetailsFieldMask(['name', 'displayName'])).toEqual([ + 'displayName', + ]); }); it('defaults to address components and display name when none provided', () => { - expect(mapToPlaceDetailsFieldMask()).toEqual(['addressComponents', 'displayName']); - expect(mapToPlaceDetailsFieldMask([])).toEqual(['addressComponents', 'displayName']); + expect(mapLegacyToNewPlaceDetailsFieldMask()).toEqual(['addressComponents', 'displayName']); + expect(mapLegacyToNewPlaceDetailsFieldMask([])).toEqual([ + 'addressComponents', + 'displayName', + ]); }); }); -describe('mapToIncludedPrimaryTypes', () => { +describe('mapLegacyToNewIncludedPrimaryTypes', () => { it('maps "address" to "street_address"', () => { - expect(mapToIncludedPrimaryTypes(['address'])).toEqual(['street_address']); + expect(mapLegacyToNewIncludedPrimaryTypes(['address'])).toEqual(['street_address']); }); it('keeps "establishment" as-is', () => { - expect(mapToIncludedPrimaryTypes(['establishment'])).toEqual(['establishment']); + expect(mapLegacyToNewIncludedPrimaryTypes(['establishment'])).toEqual(['establishment']); }); it('returns undefined for "geocode" (no direct equivalent)', () => { - expect(mapToIncludedPrimaryTypes(['geocode'])).toBeUndefined(); + expect(mapLegacyToNewIncludedPrimaryTypes(['geocode'])).toBeUndefined(); }); it('filters out null-mapped types and returns the rest', () => { - expect(mapToIncludedPrimaryTypes(['geocode', 'address'])).toEqual(['street_address']); + expect(mapLegacyToNewIncludedPrimaryTypes(['geocode', 'address'])).toEqual([ + 'street_address', + ]); }); it('returns undefined for an empty array', () => { - expect(mapToIncludedPrimaryTypes([])).toBeUndefined(); + expect(mapLegacyToNewIncludedPrimaryTypes([])).toBeUndefined(); }); }); -describe('mapToAutocompleteItems', () => { +describe('mapSuggestionsToAutocompleteItems', () => { it('maps a suggestion to an autocomplete item', () => { const mockSuggestions: google.maps.places.AutocompleteSuggestion[] = [ { @@ -67,7 +74,7 @@ describe('mapToAutocompleteItems', () => { }, ]; - const items = mapToAutocompleteItems(mockSuggestions); + const items = mapSuggestionsToAutocompleteItems(mockSuggestions); expect(items).toHaveLength(1); expect(items[0]).toMatchObject({ @@ -83,11 +90,13 @@ describe('mapToAutocompleteItems', () => { { placePrediction: null }, ]; - expect(mapToAutocompleteItems(mockSuggestionsWithoutPlacePrediction)).toEqual([]); + expect(mapSuggestionsToAutocompleteItems(mockSuggestionsWithoutPlacePrediction)).toEqual( + [], + ); }); }); -describe('mapToGeocoderAddressComponent', () => { +describe('mapNewToOldGeocoderAddressComponent', () => { it('maps an AddressComponent to a GeocoderAddressComponent', () => { const addressComponentMock = { longText: 'New York', @@ -95,7 +104,7 @@ describe('mapToGeocoderAddressComponent', () => { types: ['locality', 'political'], } as google.maps.places.AddressComponent; - expect(mapToGeocoderAddressComponent(addressComponentMock)).toEqual({ + expect(mapNewToOldGeocoderAddressComponent(addressComponentMock)).toEqual({ long_name: 'New York', short_name: 'NY', types: ['locality', 'political'], @@ -105,7 +114,7 @@ describe('mapToGeocoderAddressComponent', () => { it('falls back to empty strings and empty array when fields are missing', () => { const component = {} as google.maps.places.AddressComponent; - expect(mapToGeocoderAddressComponent(component)).toEqual({ + expect(mapNewToOldGeocoderAddressComponent(component)).toEqual({ long_name: '', short_name: '', types: [], diff --git a/packages/core/src/app/address/googleAutocomplete/newGooglePlacesApi/utils.ts b/packages/core/src/app/address/googleAutocomplete/newGooglePlacesApi/utils.ts new file mode 100644 index 0000000000..90757fa8e4 --- /dev/null +++ b/packages/core/src/app/address/googleAutocomplete/newGooglePlacesApi/utils.ts @@ -0,0 +1,63 @@ +import { type AutocompleteItem } from '@bigcommerce/checkout/ui'; + +import { type GoogleAutocompleteOptionTypes } from '../googleAutocompleteTypes'; + +const LEGACY_TO_NEW_TYPE_MAP: Record = { + geocode: null, + address: 'street_address', + establishment: 'establishment', +}; + +const LEGACY_TO_NEW_FIELD_MAP: Record = { + address_components: 'addressComponents', + name: 'displayName', +}; + +export const mapLegacyToNewIncludedPrimaryTypes = ( + legacyTypes: GoogleAutocompleteOptionTypes[], +): string[] | undefined => { + const mapped = legacyTypes + .map((legacyType) => LEGACY_TO_NEW_TYPE_MAP[legacyType]) + .filter((newType): newType is string => newType !== null); + + return mapped.length > 0 ? mapped : undefined; +}; + +export const mapSuggestionsToAutocompleteItems = ( + suggestions: google.maps.places.AutocompleteSuggestion[], +): AutocompleteItem[] => + suggestions.flatMap((suggestion) => { + const { placePrediction } = suggestion; + + if (!placePrediction) return []; + + return [ + { + label: placePrediction.text?.text ?? '', + value: placePrediction.mainText?.text ?? '', + highlightedSlices: (placePrediction.text?.matches ?? []).map( + ({ startOffset, endOffset }) => ({ + offset: startOffset, + length: endOffset - startOffset, + }), + ), + id: placePrediction.placeId, + }, + ]; + }); + +export const mapLegacyToNewPlaceDetailsFieldMask = (fields?: string[]): string[] => { + const source = fields?.length ? fields : ['address_components', 'name']; + + return Array.from(new Set(source.map((field) => LEGACY_TO_NEW_FIELD_MAP[field] ?? field))); +}; + +export const mapNewToOldGeocoderAddressComponent = (newAdressComponent: { + longText?: string | null; + shortText?: string | null; + types?: string[] | null; +}): google.maps.GeocoderAddressComponent => ({ + long_name: newAdressComponent.longText ?? '', + short_name: newAdressComponent.shortText ?? '', + types: newAdressComponent.types ?? [], +}); diff --git a/packages/core/src/app/address/googleAutocomplete/placesApiGoogleAutocomplete/getGoogleAutocompleteScriptLoader.ts b/packages/core/src/app/address/googleAutocomplete/placesApiGoogleAutocomplete/getGoogleAutocompleteScriptLoader.ts deleted file mode 100644 index 6151f48dfe..0000000000 --- a/packages/core/src/app/address/googleAutocomplete/placesApiGoogleAutocomplete/getGoogleAutocompleteScriptLoader.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { GoogleAutocompleteScriptLoader } from './GoogleAutocompleteScriptLoader'; - -let instance: GoogleAutocompleteScriptLoader; - -export function getGoogleAutocompleteScriptLoader(): GoogleAutocompleteScriptLoader { - if (!instance) { - instance = new GoogleAutocompleteScriptLoader(); - } - - return instance; -} diff --git a/packages/core/src/app/address/googleAutocomplete/placesApiGoogleAutocomplete/utils.ts b/packages/core/src/app/address/googleAutocomplete/placesApiGoogleAutocomplete/utils.ts deleted file mode 100644 index fff3f631ed..0000000000 --- a/packages/core/src/app/address/googleAutocomplete/placesApiGoogleAutocomplete/utils.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { type AutocompleteItem } from '@bigcommerce/checkout/ui'; - -import { type GoogleAutocompleteOptionTypes } from '../googleAutocompleteTypes'; - -/** - * Maps legacy Places API collection filters to valid Table A/B primary types - * accepted by the new AutocompleteSuggestion API. - * - * - 'geocode' has no direct equivalent; omitting includedPrimaryTypes gives - * broad address/region results — the same intent as the legacy filter. - * - 'address' maps to 'street_address' for address-only results. - * - 'establishment' remains valid in Table B. - */ -const LEGACY_TYPE_MAP: Record = { - geocode: null, - address: 'street_address', - establishment: 'establishment', -}; - -export const mapToIncludedPrimaryTypes = ( - types: GoogleAutocompleteOptionTypes[], -): string[] | undefined => { - const mapped = types.map((t) => LEGACY_TYPE_MAP[t]).filter((t): t is string => t !== null); - - return mapped.length > 0 ? mapped : undefined; -}; - -export const mapToAutocompleteItems = ( - suggestions: google.maps.places.AutocompleteSuggestion[], -): AutocompleteItem[] => - suggestions.flatMap((suggestion) => { - const { placePrediction } = suggestion; - - if (!placePrediction) return []; - - return [ - { - label: placePrediction.text?.text ?? '', - value: placePrediction.mainText?.text ?? '', - highlightedSlices: (placePrediction.text?.matches ?? []).map( - ({ startOffset, endOffset }) => ({ - offset: startOffset, - length: endOffset - startOffset, - }), - ), - id: placePrediction.placeId, - }, - ]; - }); - -/** - * Maps legacy Places API field names (snake_case, used by the legacy getDetails - * service) to the new Places REST API field-mask names (camelCase). Names that - * are already new-API names — or any unrecognised name — pass through unchanged, - * and the result is de-duplicated. Defaults to address + name when none given. - */ -const LEGACY_FIELD_MAP: Record = { - address_components: 'addressComponents', - name: 'displayName', -}; - -export const mapToPlaceDetailsFieldMask = (fields?: string[]): string[] => { - const source = fields?.length ? fields : ['address_components', 'name']; - - return Array.from(new Set(source.map((field) => LEGACY_FIELD_MAP[field] ?? field))); -}; - -export const mapToGeocoderAddressComponent = ( - c: { longText?: string | null; shortText?: string | null; types?: string[] | null }, -): google.maps.GeocoderAddressComponent => ({ - long_name: c.longText ?? '', - short_name: c.shortText ?? '', - types: c.types ?? [], -}); diff --git a/packages/core/tsconfig.spec.json b/packages/core/tsconfig.spec.json index 316b734ab4..58018621aa 100644 --- a/packages/core/tsconfig.spec.json +++ b/packages/core/tsconfig.spec.json @@ -1,7 +1,7 @@ { "extends": "./tsconfig.json", "compilerOptions": { - "types": ["jest", "node"], + "types": ["jest", "node", "google.maps"], }, "include": [ "**/*.test.ts", From acad608a03fe10925b019b354361d6657f0ceeda Mon Sep 17 00:00:00 2001 From: bc-maxy Date: Mon, 29 Jun 2026 19:39:36 +1000 Subject: [PATCH 15/21] fix(checkout): CHECKOUT-10026 Update test file path --- .../app/address/googleAutocomplete/GoogleAutocomplete.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/app/address/googleAutocomplete/GoogleAutocomplete.test.tsx b/packages/core/src/app/address/googleAutocomplete/GoogleAutocomplete.test.tsx index bb4fec9cc1..32b663542d 100644 --- a/packages/core/src/app/address/googleAutocomplete/GoogleAutocomplete.test.tsx +++ b/packages/core/src/app/address/googleAutocomplete/GoogleAutocomplete.test.tsx @@ -11,7 +11,7 @@ import LegacyGoogleAutocompleteService from './GoogleAutocompleteService'; import { NewGooglePlacesApiService } from './newGooglePlacesApi/NewGooglePlacesApiService'; jest.mock('./GoogleAutocompleteService'); -jest.mock('./placesApiGoogleAutocomplete/GoogleAutocompleteService'); +jest.mock('./newGooglePlacesApi/NewGooglePlacesApiService'); const MockLegacyService = LegacyGoogleAutocompleteService as jest.MockedClass< typeof LegacyGoogleAutocompleteService From 164211b5d610695d045e1fbe15b9711b9b467693 Mon Sep 17 00:00:00 2001 From: bc-maxy Date: Tue, 30 Jun 2026 15:09:01 +1000 Subject: [PATCH 16/21] fix(checkout): CHECKOUT-10026 Try new service first, fall back to old --- ....test.tsx => AutocompleteWrapper.test.tsx} | 107 +++++---- .../AutocompleteWrapper.tsx | 210 ++++++++++++++++++ .../googleAutocomplete/GoogleAutocomplete.tsx | 169 +++----------- .../GoogleAutocompleteFormField.tsx | 4 +- .../GoogleAutocompleteScriptLoader.ts | 59 ----- .../GoogleAutocompleteService.ts | 30 +-- .../getGoogleAutocompleteScriptLoader.test.ts | 56 ----- .../getGoogleAutocompleteScriptLoader.ts | 11 - .../app/address/googleAutocomplete/index.ts | 2 +- .../NewGooglePlacesApiScriptLoader.test.ts | 88 ++++++++ .../NewGooglePlacesApiScriptLoader.ts | 100 +++++++-- .../NewGooglePlacesApiService.test.ts | 129 +++++++---- .../NewGooglePlacesApiService.ts | 78 ++++--- 13 files changed, 627 insertions(+), 416 deletions(-) rename packages/core/src/app/address/googleAutocomplete/{GoogleAutocomplete.test.tsx => AutocompleteWrapper.test.tsx} (54%) create mode 100644 packages/core/src/app/address/googleAutocomplete/AutocompleteWrapper.tsx delete mode 100644 packages/core/src/app/address/googleAutocomplete/GoogleAutocompleteScriptLoader.ts delete mode 100644 packages/core/src/app/address/googleAutocomplete/getGoogleAutocompleteScriptLoader.test.ts delete mode 100644 packages/core/src/app/address/googleAutocomplete/getGoogleAutocompleteScriptLoader.ts create mode 100644 packages/core/src/app/address/googleAutocomplete/newGooglePlacesApi/NewGooglePlacesApiScriptLoader.test.ts diff --git a/packages/core/src/app/address/googleAutocomplete/GoogleAutocomplete.test.tsx b/packages/core/src/app/address/googleAutocomplete/AutocompleteWrapper.test.tsx similarity index 54% rename from packages/core/src/app/address/googleAutocomplete/GoogleAutocomplete.test.tsx rename to packages/core/src/app/address/googleAutocomplete/AutocompleteWrapper.test.tsx index 32b663542d..1cf6eb22c7 100644 --- a/packages/core/src/app/address/googleAutocomplete/GoogleAutocomplete.test.tsx +++ b/packages/core/src/app/address/googleAutocomplete/AutocompleteWrapper.test.tsx @@ -3,10 +3,8 @@ import React from 'react'; import { render, screen, waitFor } from '@bigcommerce/checkout/test-utils'; -import GoogleAutocomplete, { - type GoogleAutocompleteProps, - legacyAutocompleteState, -} from './GoogleAutocomplete'; +import AutocompleteWrapper, { newGooglePlacesApiState } from './AutocompleteWrapper'; +import { type GoogleAutocompleteProps } from './GoogleAutocomplete'; import LegacyGoogleAutocompleteService from './GoogleAutocompleteService'; import { NewGooglePlacesApiService } from './newGooglePlacesApi/NewGooglePlacesApiService'; @@ -43,7 +41,7 @@ const newApiPlaceResult = { address_components: [], } as google.maps.places.PlaceResult; -describe('GoogleAutocomplete', () => { +describe('AutocompleteWrapper', () => { let mockGetPlacePredictions: jest.Mock; let mockGetDetails: jest.Mock; let mockGetSuggestions: jest.Mock; @@ -51,7 +49,7 @@ describe('GoogleAutocomplete', () => { let defaultProps: GoogleAutocompleteProps; beforeEach(() => { - legacyAutocompleteState.isUnavailable = false; + newGooglePlacesApiState.isUnavailable = false; mockGetPlacePredictions = jest.fn(); mockGetDetails = jest.fn(); @@ -84,87 +82,98 @@ describe('GoogleAutocomplete', () => { }; }); - describe('legacy API available (existing customers)', () => { - beforeEach(() => { - mockGetPlacePredictions.mockImplementation((_req, cb) => cb(legacySuggestions, 'OK')); - mockGetDetails.mockImplementation((_req, cb) => cb(legacyPlaceResult, 'OK')); + describe('new API available (preferred path)', () => { + it('shows suggestions from the new API', async () => { + render(); + + await userEvent.type(screen.getByRole('textbox'), '123'); + + await screen.findByText('123 New API Ave'); + expect(mockGetPlacePredictions).not.toHaveBeenCalled(); }); - it('shows suggestions from the legacy service', async () => { - render(); + it('debounces rapid keystrokes into a single new API request', async () => { + render(); await userEvent.type(screen.getByRole('textbox'), '123'); - await screen.findByText('123 Legacy St, New York'); - expect(mockGetSuggestions).not.toHaveBeenCalled(); + await screen.findByText('123 New API Ave'); + + // Three characters, one request — for the full final value. + expect(mockGetSuggestions).toHaveBeenCalledTimes(1); + expect(mockGetSuggestions).toHaveBeenCalledWith('123', undefined, undefined); }); - it('calls onSelect with the legacy place result when a suggestion is picked', async () => { - render(); + it('calls onSelect with the new API place result when a suggestion is picked', async () => { + render(); await userEvent.type(screen.getByRole('textbox'), '123'); - await screen.findByText('123 Legacy St, New York'); - await userEvent.click(screen.getByText('123 Legacy St, New York')); + await screen.findByText('123 New API Ave'); + await userEvent.click(screen.getByText('123 New API Ave')); await waitFor(() => expect(defaultProps.onSelect).toHaveBeenCalledWith( - legacyPlaceResult, - expect.objectContaining({ id: 'legacy-place-1' }), + newApiPlaceResult, + expect.objectContaining({ id: 'new-place-1' }), ), ); - expect(mockGetPlaceDetails).not.toHaveBeenCalled(); + expect(mockGetDetails).not.toHaveBeenCalled(); }); }); - describe('legacy API unavailable (new customers)', () => { + describe('new API unavailable (fall back to legacy)', () => { beforeEach(() => { - mockGetPlacePredictions.mockImplementation((_req, cb) => cb(null, 'REQUEST_DENIED')); - mockGetDetails.mockImplementation((_req, cb) => cb(null, 'REQUEST_DENIED')); + mockGetPlacePredictions.mockImplementation((_req, cb) => cb(legacySuggestions, 'OK')); + mockGetDetails.mockImplementation((_req, cb) => cb(legacyPlaceResult, 'OK')); }); - it('falls back to the new API for suggestions on REQUEST_DENIED', async () => { - render(); + it('falls back to the legacy service for suggestions when the new API rejects', async () => { + mockGetSuggestions.mockRejectedValue(new Error('new API down')); + + render(); await userEvent.type(screen.getByRole('textbox'), '123'); - await screen.findByText('123 New API Ave'); - expect(mockGetSuggestions).toHaveBeenCalledWith( - expect.stringContaining('123'), - undefined, - undefined, - ); + await screen.findByText('123 Legacy St, New York'); + expect(mockGetPlacePredictions).toHaveBeenCalled(); }); - it('skips the legacy service on subsequent keystrokes after REQUEST_DENIED', async () => { - render(); + it('skips the new API on later input once it has failed', async () => { + mockGetSuggestions.mockRejectedValue(new Error('new API down')); - await userEvent.type(screen.getByRole('textbox'), '12'); + render(); - await waitFor(() => expect(mockGetSuggestions).toHaveBeenCalledTimes(2)); + const input = screen.getByRole('textbox'); - // Both characters triggered the new API; legacy was only tried once (on '1') - expect(mockGetPlacePredictions).toHaveBeenCalledTimes(1); + // First debounced request hits the new API, fails, and flips the fallback flag. + await userEvent.type(input, '1'); + await screen.findByText('123 Legacy St, New York'); + expect(mockGetSuggestions).toHaveBeenCalledTimes(1); + + // A later request goes straight to legacy without retrying the new API. + await userEvent.type(input, '2'); + await waitFor(() => expect(mockGetPlacePredictions).toHaveBeenCalledTimes(2)); + expect(mockGetSuggestions).toHaveBeenCalledTimes(1); }); - it('falls back to the new API for place details on REQUEST_DENIED', async () => { - // Return OK for suggestions so the dropdown has an item to click, - // but REQUEST_DENIED for getDetails (set in beforeEach). - mockGetPlacePredictions.mockImplementation((_req, cb) => cb(legacySuggestions, 'OK')); + it('falls back to the legacy service for place details when the new API rejects', async () => { + // Suggestions still come from the new API so the dropdown has an item to click, + // but getPlaceDetails rejects, forcing the legacy getDetails fallback. + mockGetPlaceDetails.mockRejectedValue(new Error('new API down')); - render(); + render(); - // Type a single character so only one getPlacePredictions call fires await userEvent.type(screen.getByRole('textbox'), '1'); - await screen.findByText('123 Legacy St, New York'); - await userEvent.click(screen.getByText('123 Legacy St, New York')); + await screen.findByText('123 New API Ave'); + await userEvent.click(screen.getByText('123 New API Ave')); await waitFor(() => expect(defaultProps.onSelect).toHaveBeenCalledWith( - newApiPlaceResult, - expect.objectContaining({ id: 'legacy-place-1' }), + legacyPlaceResult, + expect.objectContaining({ id: 'new-place-1' }), ), ); - expect(mockGetPlaceDetails).toHaveBeenCalledWith('legacy-place-1', undefined); + expect(mockGetDetails).toHaveBeenCalled(); }); }); }); diff --git a/packages/core/src/app/address/googleAutocomplete/AutocompleteWrapper.tsx b/packages/core/src/app/address/googleAutocomplete/AutocompleteWrapper.tsx new file mode 100644 index 0000000000..14bc457eb9 --- /dev/null +++ b/packages/core/src/app/address/googleAutocomplete/AutocompleteWrapper.tsx @@ -0,0 +1,210 @@ +import { debounce, noop } from 'lodash'; +import React, { useEffect, useMemo, useRef, useState } from 'react'; + +import { Autocomplete, type AutocompleteItem } from '@bigcommerce/checkout/ui'; + +import { type GoogleAutocompleteProps } from './GoogleAutocomplete'; +import GoogleAutocompleteService from './GoogleAutocompleteService'; +import { NewGooglePlacesApiService } from './newGooglePlacesApi'; +import './GoogleAutocomplete.scss'; + +const toAutocompleteItems = ( + results?: google.maps.places.AutocompletePrediction[], +): AutocompleteItem[] => { + return (results || []).map((result) => ({ + label: result.description, + value: result.structured_formatting.main_text, + highlightedSlices: result.matched_substrings, + id: result.place_id, + })); +}; + +// Tracks whether the new Places API has failed for this session so we can stop +// retrying it and fall back to the legacy SDK for the remainder of the session. +export const newGooglePlacesApiState = { isUnavailable: false }; + +// Wait after the last keystroke before requesting suggestions. Avoids firing (and being +// billed/throttled for) a request per character, and stops a fast burst of keystrokes from +// each launching a new-API request before the first failure can flip the fallback flag. +const SUGGESTIONS_DEBOUNCE_MS = 300; + +const AutocompleteWrapper: React.FC = ({ + initialValue, + onToggleOpen = noop, + inputProps = {}, + fields, + onSelect = noop, + nextElement, + isAutocompleteEnabled, + onChange = noop, + componentRestrictions, + types, + apiKey, +}) => { + const [items, setItems] = useState([]); + const [autoComplete, setAutoComplete] = useState('off'); + const newGooglePlacesApiServiceRef = useRef(); + const googleAutocompleteServiceRef = useRef(); + + if (!newGooglePlacesApiServiceRef.current) { + newGooglePlacesApiServiceRef.current = new NewGooglePlacesApiService(apiKey); + } + + if (!googleAutocompleteServiceRef.current) { + googleAutocompleteServiceRef.current = new GoogleAutocompleteService(apiKey); + } + + const finalizeSelection = ( + place: google.maps.places.PlaceResult | null, + item: AutocompleteItem, + ) => { + if (nextElement) { + nextElement.focus(); + } + + onSelect(place, item); + }; + + // Legacy fallbacks --------------------------------------------------------- + + const getLegacySuggestions = (input: string): void => { + googleAutocompleteServiceRef + .current!.getAutocompleteService() + .then((autocompleteService) => { + autocompleteService.getPlacePredictions( + { input, types: types || ['geocode'], componentRestrictions }, + (results) => { + setItems(toAutocompleteItems(results ?? undefined)); + }, + ); + }) + .catch(noop); + }; + + const selectViaLegacy = (item: AutocompleteItem): void => { + googleAutocompleteServiceRef + .current!.getPlacesServices() + .then((placesService) => { + placesService.getDetails( + { placeId: item.id, fields: fields || ['address_components', 'name'] }, + (result) => { + finalizeSelection(result, item); + }, + ); + }) + .catch(noop); + }; + + // New Places API (preferred) ---------------------------------------------- + + const getNewApiSuggestions = (input: string): void => { + newGooglePlacesApiServiceRef + .current!.getSuggestions(input, types, componentRestrictions) + .then(setItems) + .catch(() => { + newGooglePlacesApiState.isUnavailable = true; + getLegacySuggestions(input); + }); + }; + + const selectViaNewApi = (item: AutocompleteItem): void => { + newGooglePlacesApiServiceRef + .current!.getPlaceDetails(item.id, fields) + .then((result) => finalizeSelection(result, item)) + .catch(() => { + newGooglePlacesApiState.isUnavailable = true; + selectViaLegacy(item); + }); + }; + + // Handlers ----------------------------------------------------------------- + + const onSelectHandler = (item: AutocompleteItem) => { + if (newGooglePlacesApiState.isUnavailable) { + selectViaLegacy(item); + + return; + } + + selectViaNewApi(item); + }; + + const resetAutocomplete = (): void => { + setItems([]); + setAutoComplete('off'); + }; + + const setAutocompleteValue = (input: string): void => { + setAutoComplete(input && input.length ? 'nope' : 'off'); + }; + + const fetchSuggestions = (input: string): void => { + if (newGooglePlacesApiState.isUnavailable) { + getLegacySuggestions(input); + + return; + } + + getNewApiSuggestions(input); + }; + + // Keep a stable debounced function across renders while always invoking the latest + // fetchSuggestions closure (which reads current props/state) via the ref. + const fetchSuggestionsRef = useRef(fetchSuggestions); + + fetchSuggestionsRef.current = fetchSuggestions; + + const debouncedFetchSuggestions = useMemo( + () => + debounce( + (input: string) => fetchSuggestionsRef.current(input), + SUGGESTIONS_DEBOUNCE_MS, + ), + [], + ); + + useEffect(() => () => debouncedFetchSuggestions.cancel(), [debouncedFetchSuggestions]); + + const onChangeHandler = (input: string) => { + onChange(input, false); + + if (!isAutocompleteEnabled) { + debouncedFetchSuggestions.cancel(); + + return resetAutocomplete(); + } + + setAutocompleteValue(input); + + // Clearing the field should empty the list immediately, not after the debounce. + if (!input) { + debouncedFetchSuggestions.cancel(); + setItems([]); + + return; + } + + debouncedFetchSuggestions(input); + }; + + return ( + +
+ + ); +}; + +export default AutocompleteWrapper; diff --git a/packages/core/src/app/address/googleAutocomplete/GoogleAutocomplete.tsx b/packages/core/src/app/address/googleAutocomplete/GoogleAutocomplete.tsx index 95114807da..ebc4c45343 100644 --- a/packages/core/src/app/address/googleAutocomplete/GoogleAutocomplete.tsx +++ b/packages/core/src/app/address/googleAutocomplete/GoogleAutocomplete.tsx @@ -1,20 +1,12 @@ import { noop } from 'lodash'; -import React, { useEffect, useRef, useState } from 'react'; +import React, { useRef, useState } from 'react'; import { Autocomplete, type AutocompleteItem } from '@bigcommerce/checkout/ui'; import GoogleAutocompleteService from './GoogleAutocompleteService'; import { type GoogleAutocompleteOptionTypes } from './googleAutocompleteTypes'; -import { NewGooglePlacesApiService } from './newGooglePlacesApi'; import './GoogleAutocomplete.scss'; -declare global { - interface Window { - // Global hook the Google Maps JS SDK invokes when it processes an auth error. - gm_authFailure?: () => void; - } -} - export interface GoogleAutocompleteProps { initialValue?: string; componentRestrictions?: google.maps.places.ComponentRestrictions; @@ -40,8 +32,6 @@ const toAutocompleteItems = ( })); }; -export const legacyAutocompleteState = { isUnavailable: false }; - const GoogleAutocomplete: React.FC = ({ initialValue, onToggleOpen = noop, @@ -58,95 +48,31 @@ const GoogleAutocomplete: React.FC = ({ const [items, setItems] = useState([]); const [autoComplete, setAutoComplete] = useState('off'); const googleAutocompleteServiceRef = useRef(); - const newGooglePlacesApiServiceRef = useRef(); - const currentInputRef = useRef(''); - - // Track the latest values so the long-lived gm_authFailure handler (installed once, - // below) always reads current props rather than the values captured at mount. - const typesRef = useRef(types); - const componentRestrictionsRef = useRef(componentRestrictions); - - typesRef.current = types; - componentRestrictionsRef.current = componentRestrictions; if (!googleAutocompleteServiceRef.current) { googleAutocompleteServiceRef.current = new GoogleAutocompleteService(apiKey); } - if (!newGooglePlacesApiServiceRef.current) { - newGooglePlacesApiServiceRef.current = new NewGooglePlacesApiService(apiKey); - } - - // this is to catch errors in legacy Autocomplete SDK - useEffect(() => { - const prev = window.gm_authFailure; - - window.gm_authFailure = () => { - window.gm_authFailure = prev; - legacyAutocompleteState.isUnavailable = true; - - if (currentInputRef.current) { - newGooglePlacesApiServiceRef - .current!.getSuggestions( - currentInputRef.current, - typesRef.current, - componentRestrictionsRef.current, - ) - .then(setItems) - .catch(noop); - } - }; - - return () => { - window.gm_authFailure = prev; - }; - }, []); - - const finalizeSelection = ( - place: google.maps.places.PlaceResult | null, - item: AutocompleteItem, - ) => { - if (nextElement) { - nextElement.focus(); - } - - onSelect(place, item); - }; - - const selectViaNewApi = (item: AutocompleteItem) => - newGooglePlacesApiServiceRef - .current!.getPlaceDetails(item.id, fields) - .then((result) => finalizeSelection(result, item)) - .catch(noop); - const onSelectHandler = (item: AutocompleteItem) => { - if (legacyAutocompleteState.isUnavailable) { - void selectViaNewApi(item); - - return; - } + const service = googleAutocompleteServiceRef.current; - googleAutocompleteServiceRef - .current!.getPlacesServices() - .then((placesService) => { - placesService.getDetails( - { placeId: item.id, fields: fields || ['address_components', 'name'] }, - (result, status) => { - if (status === 'REQUEST_DENIED') { - legacyAutocompleteState.isUnavailable = true; - void selectViaNewApi(item); - - return; - } - - finalizeSelection(result, item); - }, - ); - }) - .catch(() => { - legacyAutocompleteState.isUnavailable = true; - void selectViaNewApi(item); - }); + if (!service) return; + + service.getPlacesServices().then((placesService) => { + placesService.getDetails( + { + placeId: item.id, + fields: fields || ['address_components', 'name'], + }, + (result) => { + if (nextElement) { + nextElement.focus(); + } + + onSelect(result, item); + }, + ); + }); }; const resetAutocomplete = (): void => { @@ -167,50 +93,25 @@ const GoogleAutocomplete: React.FC = ({ const service = googleAutocompleteServiceRef.current; - if (!service) { - return; - } - - if (legacyAutocompleteState.isUnavailable) { - newGooglePlacesApiServiceRef - .current!.getSuggestions(input, types, componentRestrictions) - .then(setItems) - .catch(noop); - - return; - } - - service - .getAutocompleteService() - .then((autocompleteService) => { - autocompleteService.getPlacePredictions( - { input, types: types || ['geocode'], componentRestrictions }, - (results, status) => { - if (status === 'REQUEST_DENIED') { - legacyAutocompleteState.isUnavailable = true; - newGooglePlacesApiServiceRef - .current!.getSuggestions(input, types, componentRestrictions) - .then(setItems) - .catch(noop); - - return; - } - - setItems(toAutocompleteItems(results ?? undefined)); - }, - ); - }) - .catch(() => { - legacyAutocompleteState.isUnavailable = true; - newGooglePlacesApiServiceRef - .current!.getSuggestions(input, types, componentRestrictions) - .then(setItems) - .catch(noop); - }); + if (!service) return; + + service.getAutocompleteService().then((autocompleteService) => { + autocompleteService.getPlacePredictions( + { + input, + types: types || ['geocode'], + componentRestrictions, + }, + (results) => { + const autocompleteItems = toAutocompleteItems(results ?? undefined); + + setItems(autocompleteItems); + }, + ); + }); }; const onChangeHandler = (input: string) => { - currentInputRef.current = input; onChange(input, false); if (!isAutocompleteEnabled) { diff --git a/packages/core/src/app/address/googleAutocomplete/GoogleAutocompleteFormField.tsx b/packages/core/src/app/address/googleAutocomplete/GoogleAutocompleteFormField.tsx index 2fec8edc00..d36e42d2e5 100644 --- a/packages/core/src/app/address/googleAutocomplete/GoogleAutocompleteFormField.tsx +++ b/packages/core/src/app/address/googleAutocomplete/GoogleAutocompleteFormField.tsx @@ -11,7 +11,7 @@ import { getAddressFormFieldLabelId, } from '../getAddressFormFieldInputId'; -import GoogleAutocomplete from './GoogleAutocomplete'; +import AutocompleteWrapper from './AutocompleteWrapper'; export interface GoogleAutocompleteFormFieldProps { apiKey: string; @@ -60,7 +60,7 @@ const GoogleAutocompleteFormField: FunctionComponent ( - ; - - constructor() { - this._scriptLoader = getScriptLoader(); - } - - loadMapsSdk(apiKey: string): Promise { - if (this._googleAutoComplete) { - return this._googleAutoComplete; - } - - this._googleAutoComplete = new Promise((resolve, reject) => { - const callbackName = 'initAutoComplete'; - const params = [ - 'language=en', - `key=${apiKey}`, - 'libraries=places', - `callback=${callbackName}`, - ].join('&'); - - (window as GoogleCallbackWindow)[callbackName] = () => { - if (isAutocompleteWindow(window)) { - resolve(window.google.maps); - } - - reject(new Error('Failed to initialize Google Maps Autocomplete SDK.')); - }; - - this._scriptLoader - .loadScript(`//maps.googleapis.com/maps/api/js?${params}`) - .catch((e) => { - this._googleAutoComplete = undefined; - throw e; - }); - }); - - return this._googleAutoComplete; - } -} - -function isAutocompleteWindow(window: Window): window is GoogleAutocompleteWindow { - const autocompleteWindow = window as GoogleAutocompleteWindow; - - return Boolean( - autocompleteWindow.google && - autocompleteWindow.google.maps && - autocompleteWindow.google.maps.places, - ); -} - -export interface GoogleCallbackWindow extends Window { - initAutoComplete?(): void; -} diff --git a/packages/core/src/app/address/googleAutocomplete/GoogleAutocompleteService.ts b/packages/core/src/app/address/googleAutocomplete/GoogleAutocompleteService.ts index ae5ce4840a..d4c7bde266 100644 --- a/packages/core/src/app/address/googleAutocomplete/GoogleAutocompleteService.ts +++ b/packages/core/src/app/address/googleAutocomplete/GoogleAutocompleteService.ts @@ -1,5 +1,5 @@ -import getGoogleAutocompleteScriptLoader from './getGoogleAutocompleteScriptLoader'; -import type GoogleAutocompleteScriptLoader from './GoogleAutocompleteScriptLoader'; +import { getNewGooglePlacesApiScriptLoader } from './newGooglePlacesApi/getNewGooglePlacesApiScriptLoader'; +import type { NewGooglePlacesApiScriptLoader } from './newGooglePlacesApi/NewGooglePlacesApiScriptLoader'; export default class GoogleAutocompleteService { private _autocompletePromise?: Promise; @@ -7,20 +7,18 @@ export default class GoogleAutocompleteService { constructor( private _apiKey: string, - private _scriptLoader: GoogleAutocompleteScriptLoader = getGoogleAutocompleteScriptLoader(), + // Shares the single Maps JS bootstrap with the new Places API service. Both the + // legacy and new classes live on the same `places` library, so loading it once via + // `importLibrary` serves both — and avoids injecting the SDK twice, which corrupts + // its internal state. + private _scriptLoader: NewGooglePlacesApiScriptLoader = getNewGooglePlacesApiScriptLoader(), ) {} getAutocompleteService(): Promise { if (!this._autocompletePromise) { this._autocompletePromise = this._scriptLoader - .loadMapsSdk(this._apiKey) - .then((googleMapsSdk) => { - if (!googleMapsSdk.places.AutocompleteService) { - throw new Error('`AutocompleteService` is undefined'); - } - - return new googleMapsSdk.places.AutocompleteService(); - }); + .loadPlacesLibrary(this._apiKey) + .then(({ AutocompleteService }) => new AutocompleteService()); } return this._autocompletePromise; @@ -31,14 +29,8 @@ export default class GoogleAutocompleteService { if (!this._placesPromise) { this._placesPromise = this._scriptLoader - .loadMapsSdk(this._apiKey) - .then((googleMapsSdk) => { - if (!googleMapsSdk.places.PlacesService) { - throw new Error('`PlacesService` is undefined'); - } - - return new googleMapsSdk.places.PlacesService(node); - }); + .loadPlacesLibrary(this._apiKey) + .then(({ PlacesService }) => new PlacesService(node)); } return this._placesPromise; diff --git a/packages/core/src/app/address/googleAutocomplete/getGoogleAutocompleteScriptLoader.test.ts b/packages/core/src/app/address/googleAutocomplete/getGoogleAutocompleteScriptLoader.test.ts deleted file mode 100644 index 737984d0d6..0000000000 --- a/packages/core/src/app/address/googleAutocomplete/getGoogleAutocompleteScriptLoader.test.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { getScriptLoader, type ScriptLoader } from '@bigcommerce/script-loader'; - -import GoogleAutocompleteScriptLoader, { - type GoogleCallbackWindow, -} from './GoogleAutocompleteScriptLoader'; -import { type GoogleAutocompleteWindow } from './googleAutocompleteTypes'; - -describe('GoogleAutocompleteScriptLoader', () => { - const scriptLoader: ScriptLoader = getScriptLoader(); - let googleScriptLoader: GoogleAutocompleteScriptLoader; - - describe('#loadMapsSdk()', () => { - let spiedLoadScript: any; - - beforeEach(async () => { - spiedLoadScript = jest.spyOn(scriptLoader, 'loadScript'); - - spiedLoadScript.mockImplementation(() => { - (window as GoogleAutocompleteWindow).google = { - maps: { - places: {}, - } as any, - }; - - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - (window as GoogleCallbackWindow).initAutoComplete!(); - }); - - googleScriptLoader = new GoogleAutocompleteScriptLoader(); - await googleScriptLoader.loadMapsSdk('foo'); - }); - - afterEach(() => { - spiedLoadScript.mockReset(); - }); - - it('calls loadScript with the right parameters', () => { - expect(scriptLoader.loadScript).toHaveBeenCalledWith( - [ - '//maps.googleapis.com/maps/api/js?language=en', - 'key=foo', - 'libraries=places', - 'callback=initAutoComplete', - ].join('&'), - ); - }); - - it('calls loadScript once', async () => { - await googleScriptLoader.loadMapsSdk('x'); - await googleScriptLoader.loadMapsSdk('y'); - await googleScriptLoader.loadMapsSdk('z'); - - expect(scriptLoader.loadScript).toHaveBeenCalledTimes(1); - }); - }); -}); diff --git a/packages/core/src/app/address/googleAutocomplete/getGoogleAutocompleteScriptLoader.ts b/packages/core/src/app/address/googleAutocomplete/getGoogleAutocompleteScriptLoader.ts deleted file mode 100644 index f14f2d5ed6..0000000000 --- a/packages/core/src/app/address/googleAutocomplete/getGoogleAutocompleteScriptLoader.ts +++ /dev/null @@ -1,11 +0,0 @@ -import GoogleAutocompleteScriptLoader from './GoogleAutocompleteScriptLoader'; - -let instance: GoogleAutocompleteScriptLoader; - -export default function getGoogleAutocompleteScriptLoader(): GoogleAutocompleteScriptLoader { - if (!instance) { - instance = new GoogleAutocompleteScriptLoader(); - } - - return instance; -} diff --git a/packages/core/src/app/address/googleAutocomplete/index.ts b/packages/core/src/app/address/googleAutocomplete/index.ts index 5114317360..f6eafc465d 100644 --- a/packages/core/src/app/address/googleAutocomplete/index.ts +++ b/packages/core/src/app/address/googleAutocomplete/index.ts @@ -1,5 +1,5 @@ export * from './googleAutocompleteTypes'; export { default as mapToAddress } from './mapToAddress'; -export { default as GoogleAutocompleteScriptLoader } from './GoogleAutocompleteScriptLoader'; export { default as GoogleAutocomplete } from './GoogleAutocomplete'; +export { default as AutocompleteWrapper } from './AutocompleteWrapper'; export { default as GoogleAutocompleteFormField } from './GoogleAutocompleteFormField'; diff --git a/packages/core/src/app/address/googleAutocomplete/newGooglePlacesApi/NewGooglePlacesApiScriptLoader.test.ts b/packages/core/src/app/address/googleAutocomplete/newGooglePlacesApi/NewGooglePlacesApiScriptLoader.test.ts new file mode 100644 index 0000000000..afa0c8a52d --- /dev/null +++ b/packages/core/src/app/address/googleAutocomplete/newGooglePlacesApi/NewGooglePlacesApiScriptLoader.test.ts @@ -0,0 +1,88 @@ +import { NewGooglePlacesApiScriptLoader } from './NewGooglePlacesApiScriptLoader'; + +type MutableWindow = Record; + +describe('NewGooglePlacesApiScriptLoader', () => { + const placesLibrary = { + AutocompleteSuggestion: {}, + AutocompleteSessionToken: {}, + Place: {}, + AutocompleteService: {}, + PlacesService: {}, + }; + + afterEach(() => { + delete (window as MutableWindow).google; + document.head + .querySelectorAll('script[src*="maps.googleapis.com"]') + .forEach((node) => node.remove()); + }); + + describe('when importLibrary is already available', () => { + let importLibrary: jest.Mock; + + beforeEach(() => { + importLibrary = jest.fn().mockResolvedValue(placesLibrary); + (window as MutableWindow).google = { maps: { importLibrary } }; + }); + + it('loads the places library via the existing importLibrary without bootstrapping', async () => { + const loader = new NewGooglePlacesApiScriptLoader(); + + const library = await loader.loadPlacesLibrary('foo'); + + expect(importLibrary).toHaveBeenCalledWith('places'); + expect(library).toBe(placesLibrary); + expect( + document.head.querySelector('script[src*="maps.googleapis.com"]'), + ).toBeNull(); + }); + + it('memoizes the library so importLibrary is only called once', async () => { + const loader = new NewGooglePlacesApiScriptLoader(); + + await loader.loadPlacesLibrary('foo'); + await loader.loadPlacesLibrary('foo'); + await loader.loadPlacesLibrary('foo'); + + expect(importLibrary).toHaveBeenCalledTimes(1); + }); + + it('clears the cached promise on failure so a later call can retry', async () => { + importLibrary + .mockRejectedValueOnce(new Error('places unavailable')) + .mockResolvedValueOnce(placesLibrary); + + const loader = new NewGooglePlacesApiScriptLoader(); + + await expect(loader.loadPlacesLibrary('foo')).rejects.toThrow('places unavailable'); + + const library = await loader.loadPlacesLibrary('foo'); + + expect(library).toBe(placesLibrary); + expect(importLibrary).toHaveBeenCalledTimes(2); + }); + }); + + describe('when importLibrary is missing', () => { + it('bootstraps the Maps JS loader so importLibrary becomes available', () => { + const loader = new NewGooglePlacesApiScriptLoader(); + + // The bootstrap installs `importLibrary` synchronously; the returned promise stays + // pending until the injected script fires its callback (never, in jsdom), so we do + // not await it here. + loader.loadPlacesLibrary('foo').catch(() => undefined); + + expect(typeof (window as MutableWindow).google.maps.importLibrary).toBe('function'); + + const script = document.head.querySelector( + 'script[src*="maps.googleapis.com"]', + ); + + expect(script).not.toBeNull(); + expect(script?.src).toContain('key=foo'); + expect(script?.src).toContain('libraries=places'); + expect(script?.src).toContain('callback=google.maps.__ib__'); + }); + }); +}); diff --git a/packages/core/src/app/address/googleAutocomplete/newGooglePlacesApi/NewGooglePlacesApiScriptLoader.ts b/packages/core/src/app/address/googleAutocomplete/newGooglePlacesApi/NewGooglePlacesApiScriptLoader.ts index 2a94c4172c..9c77119ecc 100644 --- a/packages/core/src/app/address/googleAutocomplete/newGooglePlacesApi/NewGooglePlacesApiScriptLoader.ts +++ b/packages/core/src/app/address/googleAutocomplete/newGooglePlacesApi/NewGooglePlacesApiScriptLoader.ts @@ -1,7 +1,74 @@ -import { getScriptLoader, type ScriptLoader } from '@bigcommerce/script-loader'; +interface MapsBootstrapConfig { + key: string; + v: string; + language?: string; +} + +/** + * Google's official inline bootstrap loader, rewritten in readable form. Loading the Maps JS + * SDK with a plain `