diff --git a/packages/core/src/app/address/AddressForm.tsx b/packages/core/src/app/address/AddressForm.tsx index 08d123e454..d12f3220f5 100644 --- a/packages/core/src/app/address/AddressForm.tsx +++ b/packages/core/src/app/address/AddressForm.tsx @@ -58,6 +58,11 @@ const AddressForm: React.FC = ({ 'CHECKOUT-9019.use_new_phone_number_validation', false, ); + const isNewGooglePlacesApiEnabled = isExperimentEnabled( + config?.checkoutSettings, + 'CHECKOUT-10026.new_google_places_api', + false, + ); const countriesWithAutocomplete = ['US', 'CA', 'AU', 'NZ', 'GB']; const containerRef = useRef(null); @@ -171,6 +176,7 @@ const AddressForm: React.FC = ({ countryCode={countryCode} field={field} isFloatingLabelEnabled={isFloatingLabelEnabledValue} + isNewPlacesApiEnabled={isNewGooglePlacesApiEnabled} key={field.id} nextElement={nextElementRef.current || undefined} onChange={handleAutocompleteChange} 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..a3ac42a030 --- /dev/null +++ b/packages/core/src/app/address/googleAutocomplete/GoogleAutocomplete.test.tsx @@ -0,0 +1,260 @@ +import userEvent from '@testing-library/user-event'; +import React from 'react'; + +import { render, screen, waitFor } from '@bigcommerce/checkout/test-utils'; + +import GoogleAutocomplete, { + type GoogleAutocompleteProps, + newGooglePlacesApiState, +} from './GoogleAutocomplete'; +import LegacyGoogleAutocompleteService from './GoogleAutocompleteService'; +import { NewGooglePlacesApiService } from './newGooglePlacesApi/NewGooglePlacesApiService'; + +jest.mock('./GoogleAutocompleteService'); +jest.mock('./newGooglePlacesApi/NewGooglePlacesApiService'); + +const MockLegacyService = LegacyGoogleAutocompleteService as jest.MockedClass< + typeof LegacyGoogleAutocompleteService +>; +const MockPlacesApiService = NewGooglePlacesApiService as jest.MockedClass< + typeof NewGooglePlacesApiService +>; + +const legacySuggestions = [ + { + description: '123 Legacy St, New York', + structured_formatting: { main_text: '123 Legacy St' }, + matched_substrings: [], + place_id: 'legacy-place-1', + }, +]; + +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; + +const gRpcPermissionDeniedErrorMock = { name: 'RpcError', code: 7 }; +const gRpcSomeOtherErrorMock = { name: 'RpcError', code: 14 }; + +describe('GoogleAutocomplete', () => { + let mockGetPlacePredictions: jest.Mock; + let mockGetDetails: jest.Mock; + let mockGetSuggestions: jest.Mock; + let mockGetPlaceDetails: jest.Mock; + let defaultProps: GoogleAutocompleteProps; + + beforeEach(() => { + newGooglePlacesApiState.isUnavailable = false; + + 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 NewGooglePlacesApiService, + ); + + defaultProps = { + apiKey: 'test-api-key', + isAutocompleteEnabled: true, + isNewPlacesApiEnabled: true, + onSelect: jest.fn(), + onChange: jest.fn(), + }; + }); + + 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('debounces rapid keystrokes into a single new API request', async () => { + render(); + + await userEvent.type(screen.getByRole('textbox'), '123'); + + 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 new API place result when a suggestion is picked', async () => { + render(); + + await userEvent.type(screen.getByRole('textbox'), '123'); + 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: 'new-place-1' }), + ), + ); + expect(mockGetDetails).not.toHaveBeenCalled(); + }); + }); + + describe('new API unavailable (fall back to legacy)', () => { + beforeEach(() => { + mockGetPlacePredictions.mockImplementation((_req, cb) => cb(legacySuggestions, 'OK')); + mockGetDetails.mockImplementation((_req, cb) => cb(legacyPlaceResult, 'OK')); + }); + + it('falls back to the legacy service for suggestions when the new API is denied', async () => { + mockGetSuggestions.mockRejectedValue(gRpcPermissionDeniedErrorMock); + + render(); + + await userEvent.type(screen.getByRole('textbox'), '123'); + + await screen.findByText('123 Legacy St, New York'); + expect(mockGetPlacePredictions).toHaveBeenCalled(); + }); + + it('does not fall back to legacy for a non-permission suggestions failure', async () => { + mockGetSuggestions.mockRejectedValue(gRpcSomeOtherErrorMock); + + render(); + + await userEvent.type(screen.getByRole('textbox'), '123'); + + await waitFor(() => expect(mockGetSuggestions).toHaveBeenCalled()); + expect(screen.queryByText('123 Legacy St, New York')).not.toBeInTheDocument(); + expect(mockGetPlacePredictions).not.toHaveBeenCalled(); + }); + + it('latches onto legacy after a permission denial and stops retrying the new API', async () => { + mockGetSuggestions.mockRejectedValue(gRpcPermissionDeniedErrorMock); + + render(); + + const input = screen.getByRole('textbox'); + + // First debounced request hits the new API, is denied, and latches 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('keeps retrying the new API on later input after a transient failure, without falling back', async () => { + mockGetSuggestions.mockRejectedValue(gRpcSomeOtherErrorMock); + + render(); + + const input = screen.getByRole('textbox'); + + // First request fails transiently; no fallback and no latch, since it's not a permission denial. + await userEvent.type(input, '1'); + await waitFor(() => expect(mockGetSuggestions).toHaveBeenCalledTimes(1)); + expect(mockGetPlacePredictions).not.toHaveBeenCalled(); + + // The new API is tried again on the next input, since the failure was not permanent. + await userEvent.type(input, '2'); + await waitFor(() => expect(mockGetSuggestions).toHaveBeenCalledTimes(2)); + }); + + it('falls back to the legacy service for place details when the new API denies permission', async () => { + // Suggestions still come from the new API so the dropdown has an item to click, + // but getPlaceDetails is denied, forcing the legacy getDetails fallback. + mockGetPlaceDetails.mockRejectedValue(gRpcPermissionDeniedErrorMock); + + render(); + + await userEvent.type(screen.getByRole('textbox'), '1'); + 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: 'new-place-1' }), + ), + ); + expect(mockGetDetails).toHaveBeenCalled(); + }); + + it('does not fall back to legacy for a non-permission place details failure', async () => { + mockGetPlaceDetails.mockRejectedValue(new Error('new API down')); + + render(); + + await userEvent.type(screen.getByRole('textbox'), '1'); + await screen.findByText('123 New API Ave'); + await userEvent.click(screen.getByText('123 New API Ave')); + + await waitFor(() => expect(mockGetPlaceDetails).toHaveBeenCalled()); + expect(mockGetDetails).not.toHaveBeenCalled(); + expect(defaultProps.onSelect).not.toHaveBeenCalled(); + }); + }); + + describe('new API disabled via experiment flag', () => { + beforeEach(() => { + mockGetPlacePredictions.mockImplementation((_req, cb) => cb(legacySuggestions, 'OK')); + mockGetDetails.mockImplementation((_req, cb) => cb(legacyPlaceResult, 'OK')); + }); + + it('goes straight to the legacy service for suggestions without calling the new API', async () => { + render(); + + await userEvent.type(screen.getByRole('textbox'), '123'); + + await screen.findByText('123 Legacy St, New York'); + expect(mockGetSuggestions).not.toHaveBeenCalled(); + }); + + it('goes straight to the legacy service for place details without calling the new API', async () => { + render(); + + 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 waitFor(() => + expect(defaultProps.onSelect).toHaveBeenCalledWith( + legacyPlaceResult, + expect.objectContaining({ id: 'legacy-place-1' }), + ), + ); + expect(mockGetPlaceDetails).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/core/src/app/address/googleAutocomplete/GoogleAutocomplete.tsx b/packages/core/src/app/address/googleAutocomplete/GoogleAutocomplete.tsx index ebc4c45343..19e998a9cc 100644 --- a/packages/core/src/app/address/googleAutocomplete/GoogleAutocomplete.tsx +++ b/packages/core/src/app/address/googleAutocomplete/GoogleAutocomplete.tsx @@ -1,10 +1,12 @@ -import { noop } from 'lodash'; -import React, { useRef, useState } from 'react'; +import { debounce, noop } from 'lodash'; +import React, { useEffect, useMemo, useRef, useState } from 'react'; import { Autocomplete, type AutocompleteItem } from '@bigcommerce/checkout/ui'; import GoogleAutocompleteService from './GoogleAutocompleteService'; import { type GoogleAutocompleteOptionTypes } from './googleAutocompleteTypes'; +import { getNewGooglePlacesApiScriptLoader, NewGooglePlacesApiService } from './newGooglePlacesApi'; +import { isNewPlacesApiPermissionDenied } from './newGooglePlacesApi/utils'; import './GoogleAutocomplete.scss'; export interface GoogleAutocompleteProps { @@ -15,6 +17,7 @@ export interface GoogleAutocompleteProps { nextElement?: HTMLElement; inputProps?: any; isAutocompleteEnabled?: boolean; + isNewPlacesApiEnabled?: boolean; types?: GoogleAutocompleteOptionTypes[]; onSelect?(place: google.maps.places.PlaceResult, item: AutocompleteItem): void; onToggleOpen?(state: { inputValue: string; isOpen: boolean }): void; @@ -32,6 +35,10 @@ const toAutocompleteItems = ( })); }; +export const newGooglePlacesApiState = { isUnavailable: false }; + +const SUGGESTIONS_DEBOUNCE_MS = 300; + const GoogleAutocomplete: React.FC = ({ initialValue, onToggleOpen = noop, @@ -40,6 +47,7 @@ const GoogleAutocomplete: React.FC = ({ onSelect = noop, nextElement, isAutocompleteEnabled, + isNewPlacesApiEnabled = false, onChange = noop, componentRestrictions, types, @@ -47,32 +55,95 @@ const GoogleAutocomplete: React.FC = ({ }) => { 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); + // When the new Places API is enabled, the legacy service must share the same + // script-loader instance as NewGooglePlacesApiService (rather than loading the Maps + // JS API a second time via the old loader), so its permission-denied fallback stays + // reliable for stores whose API key doesn't yet support the new Places API. + googleAutocompleteServiceRef.current = new GoogleAutocompleteService( + apiKey, + isNewPlacesApiEnabled ? getNewGooglePlacesApiScriptLoader() : undefined, + ); } + const finalizeSelection = ( + place: google.maps.places.PlaceResult | null, + item: AutocompleteItem, + ) => { + if (nextElement) { + nextElement.focus(); + } + + onSelect(place, item); + }; + + const handleFetchLegacySuggestions = (input: string): void => { + googleAutocompleteServiceRef + .current!.getAutocompleteService() + .then((autocompleteService) => { + autocompleteService.getPlacePredictions( + { input, types: types || ['geocode'], componentRestrictions }, + (results) => { + setItems(toAutocompleteItems(results ?? undefined)); + }, + ); + }) + .catch(noop); + }; + + const handleFetchNewApiSuggestions = (input: string): void => { + newGooglePlacesApiServiceRef + .current!.getSuggestions(input, types, componentRestrictions) + .then(setItems) + .catch((error) => { + if (isNewPlacesApiPermissionDenied(error)) { + newGooglePlacesApiState.isUnavailable = true; + handleFetchLegacySuggestions(input); + } + }); + }; + + const handleSelectViaLegacy = (item: AutocompleteItem): void => { + googleAutocompleteServiceRef + .current!.getPlacesServices() + .then((placesService) => { + placesService.getDetails( + { placeId: item.id, fields: fields || ['address_components', 'name'] }, + (result) => { + finalizeSelection(result, item); + }, + ); + }) + .catch(noop); + }; + + const handleSelectViaNewApi = (item: AutocompleteItem): void => { + newGooglePlacesApiServiceRef + .current!.getPlaceDetails(item.id, fields) + .then((result) => finalizeSelection(result, item)) + .catch((error) => { + if (isNewPlacesApiPermissionDenied(error)) { + newGooglePlacesApiState.isUnavailable = true; + handleSelectViaLegacy(item); + } + }); + }; + 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); - }, - ); - }); + if (!isNewPlacesApiEnabled || newGooglePlacesApiState.isUnavailable) { + handleSelectViaLegacy(item); + + return; + } + + handleSelectViaNewApi(item); }; const resetAutocomplete = (): void => { @@ -84,42 +155,53 @@ const GoogleAutocomplete: React.FC = ({ setAutoComplete(input && input.length ? 'nope' : 'off'); }; - const setItemsFromInput = (input: string): void => { - if (!input) { - setItems([]); + const fetchSuggestions = (input: string): void => { + if (!isNewPlacesApiEnabled || newGooglePlacesApiState.isUnavailable) { + handleFetchLegacySuggestions(input); return; } - const service = googleAutocompleteServiceRef.current; + handleFetchNewApiSuggestions(input); + }; - if (!service) return; + const fetchSuggestionsRef = useRef(fetchSuggestions); - service.getAutocompleteService().then((autocompleteService) => { - autocompleteService.getPlacePredictions( - { - input, - types: types || ['geocode'], - componentRestrictions, - }, - (results) => { - const autocompleteItems = toAutocompleteItems(results ?? undefined); + fetchSuggestionsRef.current = fetchSuggestions; - setItems(autocompleteItems); - }, - ); - }); - }; + // using ref to make sure the function is always up to date + const debouncedFetchSuggestions = useMemo( + () => + debounce( + (input: string) => fetchSuggestionsRef.current(input), + SUGGESTIONS_DEBOUNCE_MS, + ), + [], + ); + + // cleanup debounce on component unmount + useEffect(() => () => debouncedFetchSuggestions.cancel(), [debouncedFetchSuggestions]); const onChangeHandler = (input: string) => { onChange(input, false); if (!isAutocompleteEnabled) { + debouncedFetchSuggestions.cancel(); + return resetAutocomplete(); } setAutocompleteValue(input); - setItemsFromInput(input); + + // Clearing the field should empty the list immediately, not after the debounce. + if (!input) { + debouncedFetchSuggestions.cancel(); + setItems([]); + + return; + } + + debouncedFetchSuggestions(input); }; return ( diff --git a/packages/core/src/app/address/googleAutocomplete/GoogleAutocompleteFormField.tsx b/packages/core/src/app/address/googleAutocomplete/GoogleAutocompleteFormField.tsx index 2fec8edc00..3bceeaa78e 100644 --- a/packages/core/src/app/address/googleAutocomplete/GoogleAutocompleteFormField.tsx +++ b/packages/core/src/app/address/googleAutocomplete/GoogleAutocompleteFormField.tsx @@ -21,6 +21,7 @@ export interface GoogleAutocompleteFormFieldProps { nextElement?: HTMLElement; parentFieldName?: string; isFloatingLabelEnabled?: boolean; + isNewPlacesApiEnabled?: boolean; onSelect(place: google.maps.places.PlaceResult, item: AutocompleteItem): void; onToggleOpen?(state: { inputValue: string; isOpen: boolean }): void; onChange(value: string, isOpen: boolean): void; @@ -37,6 +38,7 @@ const GoogleAutocompleteFormField: FunctionComponent { const fieldName = parentFieldName ? `${parentFieldName}.${name}` : name; @@ -68,6 +70,7 @@ const GoogleAutocompleteFormField: FunctionComponent; @@ -7,7 +7,7 @@ export default class GoogleAutocompleteService { constructor( private _apiKey: string, - private _scriptLoader: GoogleAutocompleteScriptLoader = getGoogleAutocompleteScriptLoader(), + private _scriptLoader: GoogleMapsPlacesScriptLoader = getGoogleAutocompleteScriptLoader(), ) {} getAutocompleteService(): Promise { diff --git a/packages/core/src/app/address/googleAutocomplete/googleAutocompleteTypes.ts b/packages/core/src/app/address/googleAutocomplete/googleAutocompleteTypes.ts index d78c8f72d3..0abba0912a 100644 --- a/packages/core/src/app/address/googleAutocomplete/googleAutocompleteTypes.ts +++ b/packages/core/src/app/address/googleAutocomplete/googleAutocompleteTypes.ts @@ -39,6 +39,13 @@ export interface GoogleAutocompleteWindow extends Window { }; } +// Satisfied by both GoogleAutocompleteScriptLoader (legacy `@bigcommerce/script-loader` path) +// and NewGooglePlacesApiScriptLoader's adapter (importLibrary path), so +// GoogleAutocompleteService can be handed either one without depending on its concrete type. +export interface GoogleMapsPlacesScriptLoader { + loadMapsSdk(apiKey: string): Promise; +} + export type GoogleAddressFieldType = | 'postal_town' | 'administrative_area_level_1' 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..37cd89a16e --- /dev/null +++ b/packages/core/src/app/address/googleAutocomplete/newGooglePlacesApi/NewGooglePlacesApiScriptLoader.test.ts @@ -0,0 +1,109 @@ +import { NewGooglePlacesApiScriptLoader } from './NewGooglePlacesApiScriptLoader'; + +type MutableWindow = Record; + +describe('NewGooglePlacesApiScriptLoader', () => { + const placesLibrary = { + AutocompleteSuggestion: {}, + AutocompleteSessionToken: {}, + Place: {}, + AutocompleteService: {}, + PlacesService: {}, + }; + + afterEach(() => { + // this only removes the google property for test isolation + 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('#loadMapsSdk()', () => { + it('reuses the same underlying library promise as loadPlacesLibrary, without loading a second script', async () => { + const importLibrary = jest.fn().mockResolvedValue(placesLibrary); + + (window as MutableWindow).google = { maps: { importLibrary } }; + + const loader = new NewGooglePlacesApiScriptLoader(); + + await loader.loadPlacesLibrary('foo'); + + const sdk = await loader.loadMapsSdk('foo'); + + expect(importLibrary).toHaveBeenCalledTimes(1); + expect(sdk).toEqual({ + places: { + AutocompleteService: placesLibrary.AutocompleteService, + PlacesService: placesLibrary.PlacesService, + }, + }); + }); + }); + + 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 new file mode 100644 index 0000000000..5dc37cae03 --- /dev/null +++ b/packages/core/src/app/address/googleAutocomplete/newGooglePlacesApi/NewGooglePlacesApiScriptLoader.ts @@ -0,0 +1,106 @@ +interface MapsBootstrapConfig { + key: string; + v: string; + language?: string; +} + +import { type GoogleMapsSdk } from '../googleAutocompleteTypes'; + +/** + * Google's official inline Maps JavaScript API bootstrap loader based on: + * https://developers.google.com/maps/documentation/javascript/load-maps-js-api + * + * This loader is only ever reached when the new-Places-API experiment flag is on for a + * store. Stores with the flag off never construct or call this class, so they keep loading + * the Maps JS API exactly as they do today, via GoogleAutocompleteScriptLoader. + * + * When the flag IS on, both the new Places API classes and the legacy + * AutocompleteService/PlacesService classes (used as the permission-denied fallback) must + * share this same loader instance/promise. Google's Maps JS API does not tolerate being + * loaded twice on one page - injecting a second