src/frontend/components/SearchBar/SearchBar.tsx
/**
* This file is part of Sh**t! I Smoke.
*
* Copyright (C) 2018-2021 Marcelo S. Coelho, Amaury M.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import type { CSSObject } from '@emotion/serialize';
import { geoapify } from '@shootismoke/ui';
import slugify from '@sindresorhus/slugify';
import c from 'classnames';
import { pipe } from 'fp-ts/lib/pipeable';
import * as T from 'fp-ts/lib/Task';
import * as TE from 'fp-ts/lib/TaskEither';
import { NextRouter, useRouter } from 'next/router';
import React, { useEffect, useState } from 'react';
import { Props as SelectProps, StylesConfig } from 'react-select';
import AsyncSelect from 'react-select/async';
import location from '../../../../assets/images/icons/location_orange.svg';
import search from '../../../../assets/images/icons/search.svg';
import { City, logEvent, sentryException } from '../../util';
interface SearchBarProps extends SelectProps<GeoapifyOption, false> {
cities: City[];
className?: string;
showGps?: boolean;
}
interface GeoapifyOption {
label: string | React.ReactElement;
value:
| {
localeName: string;
lat: number;
lng: number;
}
| 'USE_GPS';
}
/**
* Populate the search bar results with user's input.
*/
function algoliaLoadOptions(
inputValue: string
): Promise<ReadonlyArray<GeoapifyOption>> {
return pipe(
geoapify(
inputValue,
process.env.NEXT_PUBLIC_GEOAPIFY_API_KEY as string
),
TE.map((items) => {
const found: Record<string, boolean> = {};
// Remove duplicates
return items
.filter((item) => {
if (found[item.formatted]) {
return false;
}
found[item.formatted] = true;
return true;
})
.map((item) => ({
label: item.formatted,
value: {
localeName: item.city || item.formatted,
lat: item.lat,
lng: item.lon,
},
}));
}),
TE.fold((err) => {
sentryException(err);
return T.of([]);
}, T.of)
)();
}
function defaultCustomStyle(provided: CSSObject): CSSObject {
return {
...provided,
color: '#44464A',
fontSize: '0.9rem',
};
}
const customStyles: StylesConfig<GeoapifyOption, false> = {
control: (provided) => ({
...provided,
borderRadius: '10px',
borderWidth: 1,
padding: '5px',
}),
indicatorsContainer: (provided) => ({
...provided,
display: 'none',
}),
input: (provided: CSSObject): CSSObject => {
return {
...provided,
color: '#44464A',
fontSize: '0.9rem',
zIndex: 100, // This is so that the <input> is above the <Image>, mainly for cypress tests to pass.
};
},
menu: (provided: CSSObject): CSSObject => {
return {
...provided,
minHeight: '200px',
};
},
noOptionsMessage: defaultCustomStyle,
loadingMessage: defaultCustomStyle,
option: defaultCustomStyle,
placeholder: (provided: CSSObject): CSSObject => {
return {
...provided,
color: '#44464A',
fontSize: '0.9rem',
width: '100%', // This is for truncate ellipsis.
};
},
singleValue: (provided) => ({
...provided,
width: '80%',
}),
};
/**
* Handler when a user clicks on a button to fetch browser's GPS.
*
* @param setStatus - A function to set the status of the GPS fetch.
*/
function onGps(
setStatus: (status: string | undefined) => void,
router: NextRouter
): void {
setStatus("Fetching browser's GPS location...");
if (!navigator.geolocation) {
setStatus(
'❌ Error: Geolocation is not supported for this Browser/OS.'
);
setTimeout(() => setStatus(undefined), 1500);
} else {
navigator.geolocation.getCurrentPosition(
(position) => {
router
.push(
`/city?lat=${position.coords.latitude}&lng=${position.coords.longitude}`
)
.catch((err) => {
setStatus(`❌ Error: ${(err as Error).message}`);
setTimeout(() => setStatus(undefined), 1500);
});
},
(err) => {
setStatus(`❌ Error: ${err.message}`);
setTimeout(() => setStatus(undefined), 1500);
}
);
}
}
// Default options to show when user didn't type anything in the input.
const defaultOptions: GeoapifyOption[] = [
{
label: (
<div className="flex items-center">
<img
alt="location"
className="mr-2 flex-shrink-0"
src={location}
/>
<span className="overflow-hidden truncate text-orange">
Use my location instead
</span>
</div>
),
value: 'USE_GPS',
},
];
/**
* Render an option in the dropdown.
*/
function renderOption(
text: string,
img?: string,
imgAlt?: string
): React.ReactElement {
return (
<div className="flex items-center">
{img && (
<img alt={imgAlt} className="mr-2 flex-shrink-0" src={img} />
)}
<span className="overflow-hidden truncate">{text}</span>
</div>
);
}
export function SearchBar(props: SearchBarProps): React.ReactElement {
const {
cities,
className,
placeholder = 'Search a city or address',
showGps = true,
...rest
} = props;
const router = useRouter();
// Create a lookup map for fast access.
const [citiesMap, setCitiesMap] = useState<Record<string, true>>({});
useEffect(() => {
setCitiesMap(
cities.reduce((acc, { slug }) => {
if (slug) {
acc[slug] = true;
}
return acc;
}, {} as Record<string, true>)
);
}, [cities]);
// Is the input focused?
const [isFocused, setIsFocused] = useState(false);
// The current chosen option in the dropdown.
const [option, setOption] = useState<GeoapifyOption | null>(null);
// When the option is;
// - a city: we change URL to the city page,
// - USE_GPS: we ask for user's location.
function navigateToOption(option: GeoapifyOption | null): void {
if (!option) {
return;
}
const { label, value } = option;
if (value === 'USE_GPS') {
setOption(null);
logEvent('SearchBar.Input.Gps');
onGps(setOverridePlaceholder, router);
return;
}
// If the input matches one of the slugs, then we redirect
// to the slugged page.
const sluggifiedCity = slugify(value.localeName || '');
if (citiesMap[sluggifiedCity]) {
router.push(`/city/${sluggifiedCity}`).catch(sentryException);
} else {
router
.push(
`/city?lat=${value.lat}&lng=${value.lng}&name=${
label as string
}`
)
.catch(sentryException);
}
}
// If we have a more important message to show in the placeholder, we put
// it here.
const [overridePlaceholder, setOverridePlaceholder] = useState<
string | undefined
>(undefined);
return (
<div className="relative" data-cy="SearchBar-AsyncSelect">
<AsyncSelect<GeoapifyOption, false>
className={c('w-full rounded text-gray-700', className)}
defaultOptions={defaultOptions}
// https://stackoverflow.com/questions/61290173/react-select-how-do-i-resolve-warning-prop-id-did-not-match
instanceId={1}
loadOptions={algoliaLoadOptions}
onChange={(e) => {
setOption(e);
navigateToOption(e);
}}
onFocus={(): void => {
setIsFocused(true);
logEvent('SearchBar.Input.Focus');
}}
onBlur={() => setIsFocused(false)}
placeholder={
overridePlaceholder ? (
renderOption(overridePlaceholder)
) : isFocused ? (
<span className="text-gray-600">Type something...</span>
) : (
renderOption(placeholder as string, search, 'search')
)
}
styles={customStyles}
value={option}
{...rest}
/>
{showGps && !isFocused && (
<img
alt="location"
className="absolute top-0 mt-4 mr-4 right-0 w-4 cursor-pointer"
onClick={(): void => {
logEvent('SearchBar.LocationIcon.Click');
onGps(setOverridePlaceholder, router);
}}
src={location}
/>
)}
</div>
);
}