Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support "Near Me" queries #57

Merged
merged 21 commits into from
Nov 23, 2021
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,
yen-tt marked this conversation as resolved.
Show resolved Hide resolved
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) {
cea2aj marked this conversation as resolved.
Show resolved Hide resolved
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
28 changes: 19 additions & 9 deletions sample-app/src/components/SearchBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,28 +6,38 @@ import '../sass/SearchBar.scss';
import '../sass/Autocomplete.scss';
import LoadingIndicator from './LoadingIndicator';
import { useAutocomplete } from '../hooks/useAutocomplete';
import { useRef } from 'react';
import { AutocompleteResponse } from '@yext/answers-headless';
import { executeSearchWithIntents } 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,
cea2aj marked this conversation as resolved.
Show resolved Hide resolved
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);
const responseToLatestRequestRef = useRef<Promise<AutocompleteResponse|undefined>>();
yen-tt marked this conversation as resolved.
Show resolved Hide resolved
const [ autocompleteResponse, executeAutocomplete] = useAutocomplete(isVertical);

function executeQuery () {
isVertical
? answersActions.executeVerticalQuery()
: answersActions.executeUniversalQuery();
async function executeQuery () {
const responseToLatestRequest = await responseToLatestRequestRef.current;
tmeyer2115 marked this conversation as resolved.
Show resolved Hide resolved
const intents = responseToLatestRequest?.inputIntents || [];
executeSearchWithIntents(answersActions, isVertical, intents, geolocationOptions);
}

function renderSearchButton () {
Expand All @@ -50,19 +60,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();
responseToLatestRequestRef.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 ]
};
7 changes: 6 additions & 1 deletion sample-app/src/pages/UniversalSearchPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { useLayoutEffect } from 'react';
import { useAnswersActions } from '@yext/answers-headless-react';
import '../sass/UniversalSearchPage.scss';
import { UniversalResultsConfig } from '../universalResultsConfig';
import { executeSearchWithIntents, getSearchIntents } from '../utils/search-operations';

const universalResultsFilterConfig = {
show: true
Expand All @@ -17,7 +18,11 @@ export default function UniversalSearchPage(props: { universalResultsConfig: Uni
vertical: {}
})
answersActions.setVerticalKey('');
answersActions.executeUniversalQuery();
const executeQuery = async () => {
const searchIntents = await getSearchIntents(answersActions, false);
executeSearchWithIntents(answersActions, false, searchIntents || []);
};
executeQuery();
}, [answersActions]);

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

const countryFilterOptions = [
{
Expand Down Expand Up @@ -68,7 +69,11 @@ export default function VerticalSearchPage(props: {
universal: {}
});
answersActions.setVerticalKey(props.verticalKey);
answersActions.executeVerticalQuery();
const executeQuery = async () => {
const searchIntents = await getSearchIntents(answersActions, false);
cea2aj marked this conversation as resolved.
Show resolved Hide resolved
executeSearchWithIntents(answersActions, true, searchIntents || []);
};
executeQuery();
}, [answersActions, props.verticalKey]);

return (
Expand Down
73 changes: 73 additions & 0 deletions sample-app/src/utils/search-operations.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
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, retrieve and
* store user's location in headless state. Then, execute a search with
* user's location, if that's successfully retrieved, attached to the request.
*/
export async function executeSearchWithIntents(
answersActions: AnswersActions,
isVertical: boolean,
intents: SearchIntent[],
geolocationOptions?: PositionOptions
) {
if (intents.includes(SearchIntent.NearMe)) {
try {
const position = await getUserLocation(geolocationOptions);
answersActions.setUserLocation({
latitude: position.coords.latitude,
longitude: position.coords.longitude,
});
} catch(e) {
console.error(e);
}
}
executeSearch(answersActions, isVertical);
}

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

/**
* 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> {
tmeyer2115 marked this conversation as resolved.
Show resolved Hide resolved
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.');
}
});
}