Engineering

Building a Store Locator in React using Algolia, Mapbox, and Twilio – Part 2
facebooklinkedintwittermail

This is the second article in our 3-part live-coding series on building a Store Locator. Make sure you check out Building a Store Locator – Part 1 and Building a Store Locator – Part 3.

These days, ecommerce shoppers expect convenience and want the physical and online worlds to mesh, allowing them to conduct their business on whatever channel they want. This is what a Store Locator is built for, so that users can:

  • Search products online, then purchase in-store
  • Browse and compare products in person, then buy online
  • Shop and buy products online, then pick up the purchase in person
  • Return previously purchased items in person, rather than shipping them back

Despite that, we observe that too many websites don’t offer an elegant way for their users to find their “nearest store,” whether for testing or purchasing offline, or for picking up a purchase made online.

Building a store locator may seem complex as it requires implementing a geo search, address/POI (Point of Interest) search, and displaying results on a map. To make it as easy as possible for everyone to follow along, we have decided to do a series of three Live Coding Sessions followed by three blog posts. During these events, we’ve decided to create a responsive Store Locator in React using:

  • Algolia’s geo search capabilities;
  • Mapbox for displaying a map and searching for locations and points of interest
  • Twilio for sending texts once the order is ready
  • Clever Cloud for hosting

Everything is wrapped using React v17,  React InstantSearch, and the Autocomplete UI library to build a modern search and discovery experience in little time.

Note: In Part 1  we created all required accounts (Clever cloud and Algolia), we indexed the data, and configured the Algolia index. Read Part 1

Adding Geo Search capabilities

To quickly find the nearest store from our current location or a given address input, we’re going to leverage the Algolia GeoSearch feature, along with React InstantSearch , to display the found stores on a list view and on a map view (using Mapbox GL).

Note: You can find in the project’s Github repository the code obtained at the end of part 1.

A quick overview of Algolia’s InstantSearch

React InstantSearch is an open source, production-ready UI library for React (also available in VanillaJS, Vue, Angular, Swift and Kotlin) that lets you quickly build a search interface in your front-end application.

Its goal is to help you implement awesome search experiences as smoothly as possible by providing a complete search ecosystem. InstantSearch tackles an important part of this vast goal by providing front-end UI components called “widgets” that you can assemble into unique search interfaces – such as:

In our case, we’ll leverage some of the existing widgets such as infiniteHits and RefinementList, and we’ll build a custom widget in order to display the results on a map powered by Mapbox. To do so, we’ll extend the default GeoSearch widget, which uses Google Maps by default, but can be extended thanks to the connectGeoSearch connector.

Note: By default, React InstantSearch is compatible with server-side rendering and has full routing capabilities.

Adding React InstantSearch to our app

To add InstantSearch to our app, let’s install the required packages.

yarn add algoliasearch react-instantsearch-dom

We’ll also add react-instantsearch types, for TypeScript compatibility and IDE support

 

yarn add -D @types/react-instantsearch

 

That’s all we’ll need for this part of this guide.

Let’s first grab some information from Algolia’s dashboard.

On the Algolia Dashboard, click on the Platform icon (the last icon in the sidebar) and select API Keys to copy your Search-OnlyAPI key and the Application ID.

Now, let’s create a file named .local.env. We’ll store our environment variables in this file. These variables will be injected into our application at build time,so don’t forget to restart your local development server after creating this file.

Add the following to the .local.env file :

REACT_APP_ALGOLIA_APP_ID=”myAppId”
REACT_APP_ALGOLIA_API_KEY=”xxxxx”
REACT_APP_ALGOLIA_INDEX_NAME=”myIndiceName”

Note: If environment variables are new to you, there’s plenty of extremely good resources like: Working with Environment Variables in Node.js written by my friend Dominik – lead developer evangelist at Twilio – that can help you get quickly up to speed on the topic. Another good example focuses on adding custom environment variables in Create React App apps.

Creating the Algolia client instance

Now we’ve installed the required packages, let’s add React InstantSearch to our app.The first step is to import InstantSearch at the top of our file.

import {
  InstantSearch,
} from 'react-instantsearch-dom';

Then, we can add the InstantSearch components into our code:

InstantSearch init code

As you can see, it takes two parameters:

  • indexName is the name of your Index you’ve put in Algolia’s dashboard.
  • searchClient is an instance of AlgoliaSearch, the JavaScript client library for the Algolia Search API, which will perform all the requests. As we want to keep things as simple as possible, we’ll add a separate file with this instance (moreover, we’ll need it in the third video and keeping things separated is always a good practice in software engineering).

src/lib/algoliaClient.ts

import algoliaSearch from 'algoliasearch';

const indexName = process.env.REACT_APP_ALGOLIA_INDEX_NAME;
const searchClient = algoliaSearch( process.env.REACT_APP_ALGOLIA_APP_ID as string, process.env.REACT_APP_ALGOLIA_API_KEY as string );

export { indexName, searchClient };

Now we have our client, it’s time to use it in our InstantSearch instance.

Adding our first widget to InstantSearch

InstantSearch init code

We’ll use the <Hits> widget to display the results returned by Algolia.

The Hits component takes only one prop : hitComponent, a function with an item as an argument which should return JSX. This JSX will be rendered in the app.

<Hits<Hit> hitComponent={
({ hit }) => (
      	<StoreComponent
           	key={hit.objectID}
                onClick={(hit) => handleClick(hit)}
                hit={hit}
                currentStore={currentStoreCoordinates}
            />
      )}/>

You can return any JSX you want, but we’ve created our own component, which is nothing more than a div with a bit of CSS.

Let’s have a deeper look into its props :

  • The key prop is used to help React identify, which elements of a loop it has to render., If you’re not that familiar with React, check the documentation on Lists and Keys – React :
  • The onClick prop is used to handle mouse events on the item. ;
  • The hit props are used to populate the component.
  • And finally, the currentStore prop is used to let the app be aware of which store is selected. It’ll be useful to change the pin for a different color (in our case), though the compare position, which compares two LngLat coordinates.

src/lib/comparePosition.ts

export const comparePosition = (
  position1: [number, number],
  position2: [number, number] | null
): boolean => {
  return position2
    ? position1[0] === position2[0] && position1[1] === position2[1]
    : false;
};

 StoreComponent codeNow that we have created this component, we should see a nice list of Spencer & Williams stores on the side of our app!

But the truth is: it’s not really usable. Of course, it does what we asked for (sorting by popularity, but as I’m living in Northern France (close to Lille and Belgium – yummy Chocolate and good beers), I absolutely don’t care about Atlanta’s shop.

So let’s add geosearch capabilities so it can show the most popular stores around me!

Adding Geo search

By default, Algolia offers the possibility to perform a geo search without passing any latitude/ longitude but still returning the nearest hits. How? It relies on your IP address you used to call the Algolia API.Note: For people in EU, as per July 2021, an IP address is not considered PII (Personally identifiable information, DCP for “Donnée à caractère personnelle” in French) as it doesn’t precisely locate the user,meaning that are not required to warn the user that you’re using itTo tell InstantSearch that we want to use `aroundLatLngViaIP`, we use the Configure component

InstantSearch ConfigureComponentNote: The <Configure/> component can take any SearchParameter.e’ll see more of them in the next episode.

Now, you’ll see that the list displays shops near your location. The easiest way to experiment with this is through a VPN.

With my regular connection (🇫🇷)

With a VPN connection to a Greek server (🇬🇷) (Here come the Sun 🎵)

Now we have this list of nearby shops, let’s add a nice Refinement List to let us filter on the type of services.

Unlike the Hits widget, the RefinementList component only has one prop, which is used to pass the name of the facet attribute we want to use, which we have configured in the Algolia Dashboard in part 1.

InstantSearch Refinement list config

Styling of the refinementList

Algolia is providing a default theme in a separate package. To use it, you’ll need to add the instantsearch.css stylesheet, but in our example, I wanted to explore with you a custom TailwindCSS theme using the @apply  directive which allows you to style any CSS selector  with TailwindCSS’ predefined class names like so :

.ais-RefinementList ul {
  @apply flex font-normal text-sm flex-wrap gap-2 my-4;
}

.ais-RefinementList ul li {
  @apply rounded bg-purple-200 px-2 py-1;
}

.ais-RefinementList ul input {
  display: none;
}

.ais-RefinementList-labelText {
  @apply font-medium text-purple-900;
}

.ais-RefinementList-count {
  @apply font-black text-white p-1 px-1.5 bg-purple-400 rounded-full;
}

.ais-RefinementList-item--selected {
  @apply border-2 border-purple-800;
}

.aa-Panel mark {
  color: #5468ff;
  background: none;
}

Once you’ve added these styles,more or less the refinement list widget should look like this:

Displaying stores on a map using Mapbox

If you’ve followed along, the app should look like this at this stage:

A header, a list on the side, and ¾ of empty space. It’s now the time to fill this space with a beautiful map !

Choosing the right Map provider

There are dozens of map providers, some are really well known :

  • Google Maps,
  • Apple Plans (which has a web framework since last year)
  • Mapbox (the one we’ll use)
  • Here (a consortium led by Nokia and Daimler – Mercedes-Benz, Smart, Chysler…)
  • JawgMaps
  • And Leaflet (open source)

By default, Algolia’s React InstantSearch library has a built-in widget for Google Maps. Still, to show you how to integrate any other map provider, we’ll build our new GeoSearch widget using Mapbox.com as they offer strong React and TypeScript support along with worldwide map coverage.

Mapbox has a huge field advantage in our context: a pretty well-furnished API we will use in the third episode of this series, plus the fact that they are using an open source stack :

  • Leaflet.js for the rendering engine
  • OpenStreetMap (OSM) for the data
  • CartoCSS for the design

So switching from Mapbox to another provider should not be painful.

After creating your Mapbox account, you should see something similar. Copy your Default Public Token, and copy it to your .local.env file:

REACT_APP_MAPBOX_TOKEN=pk.your_token_here

Now, let’s install the Mapbox wrapper by typing in the terminal

yarn add react-mapbox-gl mapbox-glyarn add -D @types/mapbox-gl

Next, let’s create a MapComponent directory under Components, and add a MapComponent.tsx file.

Import the required packages, and scaffold the class component:

import React, { Component } from 'react';
import ReactMapboxGl, { ZoomControl, Marker } from 'react-mapbox-gl';
import { Map } from 'mapbox-gl';

interface IMapState {
  lat: number;
  lng: number;
  zoom: number;
}

interface IMapProps {}

// Required to init the map
const ReactMap = ReactMapboxGl({
  accessToken: process.env.REACT_APP_MAPBOX_TOKEN as string,
});

class MapComponent extends Component<IMapProps, IMapState> {
  map: any;

  state = {
    lat: 30.5,
    lng: 50.5,
    zoom: 9,
  };

  render() {
    return (
      <div className={'h-full w-full relative'}>
        <div className={'h-full w-full'}>
          <ReactMap
            ref={(e) => {
              this.map = e.state.map;
            }}
            {/* This is the style, we’ll use the light one but you can try street-v10 */}
            style="mapbox://styles/mapbox/light-v10"
            containerStyle={{
              height: '100%',
              width: '100%',
              position: 'relative',
              display: 'block',
           }}
          >
            <>
           	//This adds a little +/- control to zoom in / out
      <ZoomControl position={'bottom-right'} />
            </>
          </ReactMap>
        </div>
      </div>
    );
  }
}

export default MapComponent;

We just added a full-size map thanks to react-mapbox-gl.

By injecting your newly created component in the App.tsx, you should see something like this,  so it’s now time to add our markers!

Eh… no ! That would be way too simple. To make this happen you need to add this to your craco.config.js file, which  we created last week to handle PostCSS plugins.

styles:{
//...postCSS stuff here
},
babel: {
    loaderOptions: {
      ignore: ['./node_modules/mapbox-gl/dist/mapbox-gl.js'],
    },
  },

The mapbox-gl library on npm already is transpiled, so we need to tell babel (our transpiler) to not transpile mapbox-gl.js.

Now we show the map, we’ll need to add its ‘dynamic’ behavior.

Creating a customWidget with connectGeoSearch

It’s now time to connect the map to our React InstantSearch instance. To do this, we’ll leverage the connectGeoSearch, a higher-order component, exposed in React InstantSearch.

Let’s  import the required plugins:

import { connectGeoSearch } from 'react-instantsearch-dom';

import type { GeoSearchProvided } from 'react-instantsearch-core';

The first imports  the library itself, the second imports the types required by TypeScript and IntelliSense.

First we’ll slightly update our MapComponent class declaration:

class MapComponent extends Component<GeoSearchProvided & IMapProps, IMapState>

We’ll slightly change the way we export the component:

export default connectGeoSearch(MapComponent);

After this, nothing will change in our map rendering yet but if we open React DevTools and search for our MapComponent, something will have changed…

We see that our MapComponent now has a hits prop containing all our Algolia’s index records. Now, we can focus on displaying those on the map as markers.

Let’s add a marker array, marker: [], in our state interface that we initialize as an empty array before populating the coordinates of each of our stores..

To update the displayed markers anytime a new JSON response is received from Algolia we’ll use React’s life cycle method componentWillReceiveProps in which we’ll add our logic related to adding markers to the map.

componentWillReceiveProps(nextProps){
// Let’s grab hits from nextProps and let’s compare if the two are not empty and differents from those already in props. 	 
    const { hits } = nextProps;
    if (hits.length && hits !== this.props.hits) {

	// If so let’s grab coordinates from the hit and store them into an temp array
      const markers: [number, number][] = hits.map(
        ({ _geoloc }: { _geoloc: { lat: number; lng: number } }) => {
          return [_geoloc.lng, _geoloc.lat];
        }
      );
	
// Finally let’s store this in our component state, and update the map accordingly (by centering it to the first marker) We’ll check this method in a minute, you can comment it for now.
      this.setState({ markers }, () => {
        //this.centerMapOnCoordinates(this.state.markers[0]);
      });
    }
}

To display the markers on the map, let’s create a <StoreMarker/> component in the MapComponent directory. This one has 1 property –  isSelected – to change the marker if the store is selected. It’s a really simple component, so I’ll include it here.

import React from "react";
import { ReactComponent as MarkerPin } from '../../marker.svg';
import { ReactComponent as MarkerPinSelected } from '../../marker-selected.svg';

const StoreMarker: React.FC<{isSelected: boolean}> = ({isSelected}) => {
  return isSelected ? <MarkerPinSelected className={'shadow-lg cursor-pointer'}/> : <MarkerPin className={'shadow-lg cursor-pointer'}/>;
}

export default StoreMarker

Let’s now add an extra layer of complexity to our component by wrapping it in a MapboxGL <Marker/> component. It will be responsible for adding the marker to the map. It only takes one prop (for our example, but there’s a bunch more available): coordinates:

…
import {Marker} from "react-mapbox-gl";

const StoreMarker: React.FC<{isSelected: boolean, coordinates: [number, number]}> = ({isSelected, coordinates}) => {
  return <Marker coordinates={coordinates}>
    {isSelected ? <MarkerPinSelected className={'shadow-lg cursor-pointer'}/> : <MarkerPin className={'shadow-lg cursor-pointer'}/>}
  </Marker>
}

Now in the MapComponent, let’s “map“ over our marker to display them on the map!

MapComponent/MapComponent.tsx
<>
  {this.state.markers.map((position) => {
  return (
<StoreMarker isSelected={false} coordinates={position}/>
);
  })}
 <ZoomControl position={'bottom-right'} />
</>

We now should have the markers rendered on our map.

It’s now time to “refine” this and center the map on the given markers. But before looking  closer at centerMapOnCoordinates let’s add another lifecycle method to avoid extraneous rendering. Here we are just checking if the two arrays of hits are different and block the rendering on the contrary.

shouldComponentUpdate(nextProps: Readonly<GeoSearchProvided>): boolean {
    return nextProps.hits !== this.props.hits;
}

Let’s move on to centerMapOnCoordinates. This is probably the most complicated bit of code in this week’s part.

Note: Don’t hesitate to watch the livecoding available at the top of the article in case you’d like more information about the different steps.

So let’s split this:

We play with the isUserInterraction state Boolean to avoid entering the map if the call is triggered by a user action on the map.

If it’s not, we temporarily set it to true, and center the map on the first marker (which is, per our configuration, the store with the highest popularity attribute on the viewport).

Then, we call the refine method provided by the hook, which allows us to request Algolia’s data based on the map boundaries: here north-east and south-west (top-right, and bottom-left).

centerMapOnCoordinates(coordinates: [number, number]) {
    if (!this.state.isUserInteraction) {
      const { refine } = this.props;
      this.setState({ isUserInteraction: true }, () => {
        this.map.state.map.flyTo({
          essential: false,
          center: [coordinates[0], coordinates[1]],
          zoom: 7,
        });


        refine({
          northEast: this.map.state.map.getBounds().getNorthEast(),
          southWest: this.map.state.map.getBounds().getSouthWest(),
        });
      });
    }
  }

Add “Refresh as the map moves” option. 

The last method needed in this map component will be used to refine the map marker when we move the map! It’s a self-explanatory method; we already used all of the methods called in its body previously.

onMapInteraction(map: Map) {
    if (this.state.userInteractionEnabled) {
      const { refine } = this.props;

      refine({
        northEast: map.getBounds().getNorthEast(),
        southWest: map.getBounds().getSouthWest(),
      });
    }
  }

The only new parameter here is the userInteractionEnabled Boolean which changes based on the checkbox we’ll add next!

To handle this, and to keep our code as clear as possible, let’s create a new component in the MapComponent/ directory. We will call it SearchAsMoving once more to keep our component simple and stupid (KISS methodology). It’s a simple div with a text and a input type=”checkbox”

interface ISearchAsMoving {
  checked: boolean,
  onChange: (checked: boolean) => void
}

const SearchAsMoving: React.FC<ISearchAsMoving> = ({checked, onChange}) => {
  return (
    <div>
      <input
        defaultChecked={checked}
        type={'checkbox'}
        onChange={(e) =>
          onChange(e.target.checked)
        }
      />
      Search when I move the map
    </div>
  )
}

export default SearchAsMoving

We pass two props to this method, the default check status (checked) and the method we want to trigger when we change the state of the checkbox.

We can now add this to our map, under the <ReactMap/> component and we’ll be good !

<SearchAsMoving 
    checked={this.state.userInteractionEnabled}   onChange={(userInteractionEnabled) => this.setState({userInteractionEnabled})}/>

Once done let’s add the onMapInteraction to the map itself, and let’s wrap up !

<ReactMap
            ref={(e) => { this.map = e?.state.map; }}
            style={...}
            containerStyle={{...}}
            onMoveEnd={this.onMapInteraction}
>...

Let’s wrap up !

So based on last week work :

  • We first added the data in a side list using <InstantSearch> and <Hits> wrappers
  • We’ve refined this list with the <Configure/> component and we passed ‘aroundLatLngViaIP’ in order to get results around us.
  • We’ve created a Map using Mapbox
  • We’ve connected it to our React InstantSearch instance using connectGeoSearch() HOC.
  • Finally we’ve added a Search as I move the map checkbox to improve our UX.

Stat tuned for the part 3 in which we’ll see

  • How add an Autocomplete that allows to search for locations
  • How to use Twilio to send text message whenever a product is available in a store

Note: the code of today’s session is available in the Github repository.

About the authorClément Sauvage

Clément Sauvage

Software Engineer, Freelance

Recommended Articles

Powered by Algolia AI Recommendations

Building a Store Locator in React using Algolia, Mapbox, and Twilio – Part 3
Engineering

Building a Store Locator in React using Algolia, Mapbox, and Twilio – Part 3

Clément Sauvage

Clément Sauvage

Software Engineer, Freelance
Adding trending recommendations to your existing e-commerce store
Engineering

Adding trending recommendations to your existing e-commerce store

Ashley Huynh

Ashley Huynh

Build a React app with fast indexing and instant inventory updates
Engineering

Build a React app with fast indexing and instant inventory updates

Julia Seidman

Julia Seidman

Developer Educator