Skip to content

Commit

Permalink
Support "Near Me" queries (#57)
Browse files Browse the repository at this point in the history
Add support for 'Near me' queries in search bar

- added `search-operations` file that includes util functions for executing search, getting search intents, and user location
- update LocationBias component to retrieve user location and execute search using SearchHandler.
- updated `VerticalSearchPage` and `UniversalSearchPage` fetch searchIntents from autocomplete search, in the case of a default initial search with near me intent, get and update user's location if there is a near me intent, and execute query.
- updated searchBar to rely on autocomplete response from latest request to retrieve the search intent, and update user's location before executing a search

Note: there's a noticeable wait time when navigator.geolocation is used. and there isn't any indication that the results maybe  in loading state. Should consider exposing a setLoadingStatus in answersHeadless, so it can be use when executeSearchWithUserLocation is triggered. 

J=SLAP-1706
TEST=manual

- set location to washington DC in when vertical page is loaded, click update my location from `LocationBias` component, and see that the label is updated to my device's location.
- set default search query to 'engineers near me' and see that user location is set based on location near my device.
- ran searches between non-nearMe and nearMe queries, see that setUserLocation is only on queries with nearMe intents
  • Loading branch information
yen-tt committed Nov 23, 2021
1 parent f9e4fd7 commit fefc014
Show file tree
Hide file tree
Showing 6 changed files with 165 additions and 49 deletions.
44 changes: 18 additions & 26 deletions sample-app/src/components/LocationBias.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,9 @@
import { useAnswersActions, useAnswersState, LocationBiasMethod } from '@yext/answers-headless-react';
import { executeSearch, getUserLocation } from '../utils/search-operations';

interface Props {
isVertical: boolean,
geolocationOptions?: {
enableHighAccuracy?: boolean,
timeout?: number,
maximumAge?: number
},
geolocationOptions?: PositionOptions,
cssClasses?: {
container: string,
location: string,
Expand All @@ -15,11 +12,6 @@ interface Props {
}
}

const defaultGeolocationOptions = {
enableHighAccuracy: false,
timeout: 6000,
maximumAge: 300000,
};

const defaultCSSClasses = {
container: 'LocationBias',
Expand All @@ -30,9 +22,9 @@ const defaultCSSClasses = {

export default function LocationBias(props: Props) {
const answersActions = useAnswersActions();
const { isVertical, geolocationOptions, cssClasses: customCssClasses } = props;
const locationBias = useAnswersState(s => s.location.locationBias)
const geolocationOptions = Object.assign(defaultGeolocationOptions, props.geolocationOptions);
const cssClasses = Object.assign(defaultCSSClasses, props.cssClasses);
const cssClasses = Object.assign(defaultCSSClasses, customCssClasses);

if (!locationBias?.displayName) return null;

Expand All @@ -41,20 +33,17 @@ export default function LocationBias(props: Props) {
: locationBias?.method === LocationBiasMethod.Device ? '(based on your device)'
: '';

function handleGeolocationClick() {
if ('geolocation' in navigator) {
navigator.geolocation.getCurrentPosition((position) => {
answersActions.setUserLocation({
latitude: position.coords.latitude,
longitude: position.coords.longitude,
})
props.isVertical
? answersActions.executeVerticalQuery()
: answersActions.executeUniversalQuery()
},
(err) => console.error(err),
geolocationOptions);
async function handleGeolocationClick() {
try {
const position = await getUserLocation(geolocationOptions);
answersActions.setUserLocation({
latitude: position.coords.latitude,
longitude: position.coords.longitude,
});
} catch (e) {
console.error(e);
}
executeSearch(answersActions, isVertical);
}

return (
Expand All @@ -67,7 +56,10 @@ export default function LocationBias(props: Props) {
{attributionMessage}
</span>
)}
<button className={cssClasses.button} onClick={handleGeolocationClick}>
<button
className={cssClasses.button}
onClick={handleGeolocationClick}
>
Update your location
</button>
</div>
Expand Down
36 changes: 27 additions & 9 deletions sample-app/src/components/SearchBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,28 +6,46 @@ import '../sass/SearchBar.scss';
import '../sass/Autocomplete.scss';
import LoadingIndicator from './LoadingIndicator';
import { useAutocomplete } from '../hooks/useAutocomplete';
import { useRef } from 'react';
import { AutocompleteResponse, SearchIntent } from '@yext/answers-headless';
import { executeSearch, updateLocationIfNeeded } from '../utils/search-operations';

const SCREENREADER_INSTRUCTIONS = 'When autocomplete results are available, use up and down arrows to review and enter to select.'

interface Props {
placeholder?: string,
isVertical: boolean,
geolocationOptions?: PositionOptions,
screenReaderInstructionsId: string
}

/**
* Renders a SearchBar that is hooked up with an Autocomplete component
*/
export default function SearchBar({ placeholder, isVertical, screenReaderInstructionsId }: Props) {
export default function SearchBar({
placeholder,
isVertical,
geolocationOptions,
screenReaderInstructionsId
}: Props) {
const answersActions = useAnswersActions();
const query = useAnswersState(state => state.query.input);
const [ autocompleteResults, executeAutocomplete ] = useAutocomplete(isVertical);
const isLoading = useAnswersState(state => state.searchStatus.isLoading);
/**
* Allow a query search to wait on the response to the autocomplete request right
* before the search execution in order to retrieve the search intents
*/
const autocompletePromiseRef = useRef<Promise<AutocompleteResponse|undefined>>();
const [ autocompleteResponse, executeAutocomplete] = useAutocomplete(isVertical);

function executeQuery () {
isVertical
? answersActions.executeVerticalQuery()
: answersActions.executeUniversalQuery();
async function executeQuery () {
let intents: SearchIntent[] = [];
if (!answersActions.state.location.userLocation) {
const autocompleteResponseBeforeSearch = await autocompletePromiseRef.current;
intents = autocompleteResponseBeforeSearch?.inputIntents || [];
await updateLocationIfNeeded(answersActions, intents, geolocationOptions);
}
executeSearch(answersActions, isVertical);
}

function renderSearchButton () {
Expand All @@ -50,19 +68,19 @@ export default function SearchBar({ placeholder, isVertical, screenReaderInstruc
placeholder={placeholder}
screenReaderInstructions={SCREENREADER_INSTRUCTIONS}
screenReaderInstructionsId={screenReaderInstructionsId}
options={autocompleteResults.map(result => {
options={autocompleteResponse?.results.map(result => {
return {
value: result.value,
render: () => renderWithHighlighting(result)
}
})}
}) ?? []}
optionIdPrefix='Autocomplete__option'
onSubmit={executeQuery}
updateInputValue={value => {
answersActions.setQuery(value);
}}
updateDropdown={() => {
executeAutocomplete();
autocompletePromiseRef.current = executeAutocomplete();
}}
renderButtons={renderSearchButton}
cssClasses={{
Expand Down
33 changes: 21 additions & 12 deletions sample-app/src/hooks/useAutocomplete.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,28 @@
import { useRef, useState } from "react";
import { AutocompleteResult, useAnswersActions } from '@yext/answers-headless-react';
import { AutocompleteResponse, useAnswersActions } from '@yext/answers-headless-react';

export function useAutocomplete(isVertical: boolean): [AutocompleteResult[], () => Promise<void>] {
export function useAutocomplete(
isVertical: boolean
): [
AutocompleteResponse|undefined,
() => Promise<AutocompleteResponse|undefined>
]
{
const answersActions = useAnswersActions();
const autocompleteNetworkIds = useRef({ latestRequest: 0, responseInState: 0 });
const [ autocompleteResults, setAutocompleteResults ] = useState<AutocompleteResult[]>([]);
async function executeAutocomplete () {
const [ autocompleteResponse, setAutocompleteResponse ] = useState<AutocompleteResponse>();
async function executeAutocomplete (): Promise<AutocompleteResponse|undefined> {
const requestId = ++autocompleteNetworkIds.current.latestRequest;
const response = isVertical
? await answersActions.executeVerticalAutocomplete()
: await answersActions.executeUniversalAutocomplete();
if (requestId >= autocompleteNetworkIds.current.responseInState) {
setAutocompleteResults(response?.results || []);
autocompleteNetworkIds.current.responseInState = requestId;
}
return new Promise(async (resolve) => {
const response = isVertical
? await answersActions.executeVerticalAutocomplete()
: await answersActions.executeUniversalAutocomplete();
if (requestId >= autocompleteNetworkIds.current.responseInState) {
setAutocompleteResponse(response);
autocompleteNetworkIds.current.responseInState = requestId;
}
resolve(response);
});
}
return [ autocompleteResults, executeAutocomplete ]
return [ autocompleteResponse, executeAutocomplete ]
};
16 changes: 15 additions & 1 deletion sample-app/src/pages/UniversalSearchPage.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
import UniversalResults from '../components/UniversalResults';
import { useLayoutEffect } from 'react';
import { useAnswersActions } from '@yext/answers-headless-react';
import { SearchIntent } from '@yext/answers-headless';
import '../sass/UniversalSearchPage.scss';
import { UniversalResultsConfig } from '../universalResultsConfig';
import {
executeSearch,
getSearchIntents,
updateLocationIfNeeded
} from '../utils/search-operations';

const universalResultsFilterConfig = {
show: true
Expand All @@ -17,7 +23,15 @@ export default function UniversalSearchPage(props: { universalResultsConfig: Uni
vertical: {}
})
answersActions.setVerticalKey('');
answersActions.executeUniversalQuery();
const executeQuery = async () => {
let searchIntents: SearchIntent[] = [];
if (!answersActions.state.location.userLocation) {
searchIntents = await getSearchIntents(answersActions, false) || [];
updateLocationIfNeeded(answersActions, searchIntents);
}
executeSearch(answersActions, false);
};
executeQuery();
}, [answersActions]);

return (
Expand Down
16 changes: 15 additions & 1 deletion sample-app/src/pages/VerticalSearchPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,12 @@ import '../sass/VerticalSearchPage.scss';
import { StandardCard } from '../components/cards/StandardCard';
import { useLayoutEffect } from 'react';
import { useAnswersActions } from '@yext/answers-headless-react';
import { SearchIntent } from '@yext/answers-headless';
import {
executeSearch,
getSearchIntents,
updateLocationIfNeeded
} from '../utils/search-operations';

const countryFilterOptions = [
{
Expand Down Expand Up @@ -68,7 +74,15 @@ export default function VerticalSearchPage(props: {
universal: {}
});
answersActions.setVerticalKey(props.verticalKey);
answersActions.executeVerticalQuery();
const executeQuery = async () => {
let searchIntents: SearchIntent[] = [];
if (!answersActions.state.location.userLocation) {
searchIntents = await getSearchIntents(answersActions, true) || [];
await updateLocationIfNeeded(answersActions, searchIntents);
}
executeSearch(answersActions, true);
};
executeQuery();
}, [answersActions, props.verticalKey]);

return (
Expand Down
69 changes: 69 additions & 0 deletions sample-app/src/utils/search-operations.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { AnswersActions } from "@yext/answers-headless-react";
import { SearchIntent } from "@yext/answers-headless";

const defaultGeolocationOptions: PositionOptions = {
enableHighAccuracy: false,
timeout: 6000,
maximumAge: 300000,
};

/**
* If the provided search intents include a 'NEAR_ME' intent and there's no existing
* user's location in state, retrieve and store user's location in headless state.
*/
export async function updateLocationIfNeeded(
answersActions: AnswersActions,
intents: SearchIntent[],
geolocationOptions?: PositionOptions
) {
if (intents.includes(SearchIntent.NearMe) && !answersActions.state.location.userLocation) {
try {
const position = await getUserLocation(geolocationOptions);
answersActions.setUserLocation({
latitude: position.coords.latitude,
longitude: position.coords.longitude,
});
} catch(e) {
console.error(e);
}
}
}

/**
* Executes a universal/vertical search
*/
export async function executeSearch(answersActions: AnswersActions, isVertical: boolean) {
isVertical
? answersActions.executeVerticalQuery()
: answersActions.executeUniversalQuery();
}

/**
* Get search intents of the current query stored in headless using autocomplete request.
*/
export async function getSearchIntents(answersActions: AnswersActions, isVertical: boolean) {
const results = isVertical
? await answersActions.executeVerticalAutocomplete()
: await answersActions.executeUniversalAutocomplete();
return results?.inputIntents;
}

/**
* Retrieves user's location using nagivator.geolocation API
*/
export async function getUserLocation(geolocationOptions?: PositionOptions): Promise<GeolocationPosition> {
return new Promise((resolve, reject) => {
if ('geolocation' in navigator) {
navigator.geolocation.getCurrentPosition(
position => resolve(position),
err => {
console.error('Error occured using geolocation API. Unable to determine user\'s location.');
reject(err);
},
{ ...defaultGeolocationOptions, ...geolocationOptions }
);
} else {
reject('No access to geolocation API. Unable to determine user\'s location.');
}
});
}

0 comments on commit fefc014

Please sign in to comment.