Skip to content
Draft
Show file tree
Hide file tree
Changes from 7 commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
588c55f
fix(checkout): CHECKOUT-10026 migrate Google Places script loader to …
bc-maxy Jun 17, 2026
bd75dcc
fix(checkout): CHECKOUT-10026 Migrate GoogleAutocompleteService
bc-maxy Jun 17, 2026
a9876e5
fix(checkout): CHECKOUT-10026 Update GoogleAutocomplete file
bc-maxy Jun 19, 2026
1322a5a
fix(checkout): CHECKOUT-10026 Various quick fixes
bc-maxy Jun 19, 2026
f7af121
fix(checkout): CHECKOUT-10026 Resolve cursor comments
bc-maxy Jun 19, 2026
a5d1b4b
fix(checkout): CHECKOUT-10026 Add session token to suggestion calls
bc-maxy Jun 19, 2026
e02bfce
fix(checkout): CHECKOUT-10026 Various small cleanup
bc-maxy Jun 19, 2026
b93d093
fix(checkout): CHECKOUT-10026 Keep the old service alive
bc-maxy Jun 24, 2026
491717a
chore(checkout): CHECKOUT-10026 Clean up the code
bc-maxy Jun 24, 2026
dd051c8
feat(checkout): CHECKOUT-10026 Fall back to new service if old one fails
bc-maxy Jun 24, 2026
0e95de2
fix(checkout): CHECKOUT-10026 Adjust functions to work correctly
bc-maxy Jun 24, 2026
9389b14
fix(checkout): CHECKOUT-10026 Use module scoped variable
bc-maxy Jun 24, 2026
9672f94
fix(checkout): CHECKOUT-10026 Fix review comments
bc-maxy Jun 29, 2026
9796c11
fix(checkout): CHECKOUT-10026 Clean up the implementation
bc-maxy Jun 29, 2026
acad608
fix(checkout): CHECKOUT-10026 Update test file path
bc-maxy Jun 29, 2026
164211b
fix(checkout): CHECKOUT-10026 Try new service first, fall back to old
bc-maxy Jun 30, 2026
34537f2
fix(checkout): CHECKOUT-10026 Clean up the implementation
bc-maxy Jun 30, 2026
bcc8a31
fix(checkout): CHECKOUT-10026 A small refactor
bc-maxy Jun 30, 2026
974d130
chore(checkout): CHECKOUT-10026 Fix typos and unused exports
bc-maxy Jun 30, 2026
45efb30
fix(checkout): CHECKOUT-10026 Fall back to legacy only for permission…
bc-maxy Jul 1, 2026
9648939
fix(checkout): CHECKOUT-10026 Hide behind feature flag WIP
bc-maxy Jul 1, 2026
08fc93e
Merge branch 'master' into checkout-10026
bc-maxy Jul 1, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,13 @@ 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,
mapToGeocoderAddressComponent,
mapToIncludedPrimaryTypes,
} from './utils';
import './GoogleAutocomplete.scss';

export interface GoogleAutocompleteProps {
Expand All @@ -21,18 +26,7 @@ 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<GoogleAutocompleteProps> = ({
export const GoogleAutocomplete: React.FC<GoogleAutocompleteProps> = ({
initialValue,
onToggleOpen = noop,
inputProps = {},
Expand All @@ -53,26 +47,30 @@ const GoogleAutocomplete: React.FC<GoogleAutocompleteProps> = ({
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);
})
.catch(() => {
// keep user suggestion text if api call fails
onSelect({ name: item.label }, item);
resetAutocomplete();
});
};

const resetAutocomplete = (): void => {
Expand All @@ -95,23 +93,17 @@ const GoogleAutocomplete: React.FC<GoogleAutocompleteProps> = ({

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,
mapToIncludedPrimaryTypes(types || ['geocode']),
componentRestrictions,
)
.then((suggestions) => setItems(mapToAutocompleteItems(suggestions)))
.catch(() => setItems([]));
};

const onChangeHandler = (input: string) => {
const handleChange = (input: string) => {
onChange(input, false);

if (!isAutocompleteEnabled) {
Expand All @@ -133,13 +125,11 @@ const GoogleAutocomplete: React.FC<GoogleAutocompleteProps> = ({
}}
items={items}
listTestId="address-autocomplete-suggestions"
onChange={onChangeHandler}
onSelect={onSelectHandler}
onChange={handleChange}
onSelect={handleSelect}
onToggleOpen={onToggleOpen}
>
<div className="co-googleAutocomplete-footer" />
</Autocomplete>
);
};

export default GoogleAutocomplete;
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import {
getAddressFormFieldLabelId,
} from '../getAddressFormFieldInputId';

import GoogleAutocomplete from './GoogleAutocomplete';
import { GoogleAutocomplete } from './GoogleAutocomplete';

export interface GoogleAutocompleteFormFieldProps {
apiKey: string;
Expand Down Expand Up @@ -50,12 +50,12 @@ const GoogleAutocompleteFormField: FunctionComponent<GoogleAutocompleteFormField
'floating-input floating-form-field-input': isFloatingLabelEnabled,
}),
id: getAddressFormFieldInputId(name),
'aria-labelledby': labelId,
'aria-labelledby': getAddressFormFieldLabelId(name),
placeholder: isFloatingLabelEnabled ? ' ' : placeholder,
labelText: isFloatingLabelEnabled ? labelContent : null,
maxLength: maxLength || undefined,
}),
[name, labelId, placeholder, labelContent, maxLength],
[name, placeholder, labelContent, maxLength, isFloatingLabelEnabled],
);

const renderInput = useCallback(
Expand Down
Original file line number Diff line number Diff line change
@@ -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<google.maps.PlacesLibrary>;

export default class GoogleAutocompleteScriptLoader {
private _scriptLoader: ScriptLoader;
private _googleAutoComplete?: Promise<GoogleMapsSdk>;

constructor() {
this._scriptLoader = getScriptLoader();
}

loadMapsSdk(apiKey: string): Promise<GoogleMapsSdk> {
if (this._googleAutoComplete) {
return this._googleAutoComplete;
loadPlacesLibrary(apiKey: string): Promise<google.maps.PlacesLibrary> {
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<google.maps.PlacesLibrary>)
.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;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
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);
});
});
});
Loading