Googlemap Hero Image

Building a Store Locator with Craft CMS and Smart Map

This is a tutorial on how to build a store locator using Craft CMS with location detection and user submitted filtering. This could also function as a dealer locator or finder.

This is a tutorial on how to build a store locator using Craft CMS with location detection and user submitted filtering. This could also function as a dealer locator or finder. I'm working on a new site build which has a store locator page. On page load the map loads up all stores within 100 kilometers of the visitors location. If there are no results within that range then the map will display all locations across Canada and a no results message. 

The visitor additionally has the option to search locations by entering a city, postal code, or selecting a province from a dropdown. If searching for a city/postal code with or without a province selected all results within 100 kilometers are displayed. If the visitor only selects the province all location within the province are displayed. 

The store locator is built with Craft CMS and the excellent Smart Map plugin.

Enable IP Detection

Before proceeding you will need to enable geolocation as it is not turned on by default. To do this go to the Smart Map settings and change it from None to FreeGeolph.net or MaxMind. The developer of Smart Map strongly recommends using MaxMind over FreeGeolph. See the docs for this quote:

FreeGeoIp.net may experience unexpected downtime, which could have a negative impact on your website! It is highly recommended not to rely on FreeGeoIp.net.

If you require geolocation, MaxMind is a far more reliable service.

Smart Map documentation

Setting Variables and the Form for Filtering Results

The first thing we need to do is set some variables to be used throughout our code.  The code below starts by setting variables for any get values that may exist and also an arrray of values from Smartmap that we can use for ip location detection.

Next we set valid provinces and map province long names to their abbreviations.

{% set query = craft.request.getQuery('query') %}
{% set province = craft.request.getQuery('province') %}
{% set formPost = query or province %}

{# get the ip location stuff from smartmap #}
{% set visitor = craft.smartMap.visitor %}

{# add provinces where stores exist - needed for ip location  #}
{% set validProvinces = ['Alberta', 'British Columbia', 'Manitoba', 'Ontario', 'Nova Scotia'] %}


{% set provinceMapping = {
    '' : 'Province',
    'AB' : 'Alberta',
    'BC' : 'British Columbia',
    'MB' : 'Manitoba',
    'NB' : 'New Brunswick',
    'NL' : 'Newfoundland',
    'NS' : 'Nova Scotia',
    'ON' : 'Ontario',
    'PE' : 'PEI',
    'QC' : 'Quebec',
    'SK' : 'Saskatchewan',
    'NU' : 'Nunavut',
    'YT' : 'Yukon',
} %}
{# p= long name province= shortname #}
{% set p = provinceMapping[province] %}

Next is a pretty standard form with the action url returning us to the page we are already on. 

<form action="/store/find" method="get">
    <input type="search" name="query" id="query" class="form-control" placeholder="City or Postal Code" >
    <select class="form-control" name="province" id="province">
      <option value="">Province</option>
      <option value="AB">Alberta</option>
      <option value="BC">British Columbia</option>
      <option value="MB">Manitoba</option>
      <option value="NB">New Brunswick</option>
      <option value="NL">Newfoundland</option>
      <option value="NS">Nova Scotia</option>
      <option value="ON">Ontario</option>
      <option value="PE">PEI</option>
      <option value="QC">Quebec</option>
      <option value="SK">Saskatchewan</option>
      <option value="NU">Nunavut</option>
      <option value="YT">Yukon</option>
    </select>
    <button class="btn btn-primary" type="submit">Search</button>
</form>

Map Options

The next section of code is setting the map options.  We use a conditional to check if visitor ip info is available and that the visitor is in a valid province to set options for the map and then alternative options if not.

Inside map options I have 3 globals set up so that the client can set the center of the default map and zoom level. These are findAStore.mapCenterLatitudefindAStore.mapCenterLongitude, and findAStore.zoomLevel.

{% if not formPost %}

  {#Set options according to detected location #}
  {% if visitor and (visitor.state in validProvinces) %}

      {# options for iplocation map #}
      {% set options = {
          id: 'storeLocator',
          height: 615,
          width: 1090,
          zoomControl: true,
          draggable: true,
          markerInfo: '_includes/mapInfoBubble',
          markerOptions: {
              icon: 'http://maps.google.com/mapfiles/ms/icons/green-dot.png'
          },
          infoWindowOptions: {
              maxWidth: 200
          },
      } %}

  {# Set default options #}
  {% else %}

      {# options for default map #}
      {% set options = options | merge({center: {
          'lat':findAStore.mapCenterLatitude,
          'lng':findAStore.mapCenterLongitude
        }, 'zoom':findAStore.zoomLevel}) %}

  {% endif %}
  {# end default map options #}

{% else %}

  {# begin filtered map options i.e. no center set #}
  {% set options = {
      id: 'storeLocator',
      height: 615,
      width: 1090,
      zoom: 7,
      zoomControl: true,
      draggable: true,
      markerInfo: '_includes/mapInfoBubble',
      markerOptions: {
          icon: 'http://maps.google.com/mapfiles/ms/icons/green-dot.png'
      },
      infoWindowOptions: {
          maxWidth: 200
      },
  } %}
  {# end filtered map otions #}
{% endif %}

Rendering the Map

This site has a channel with a handle of stores and a smartmap field assigned to that channel with the handle of mapAddress. Once again we set up a conditional to determine if the form has not been submitted. The empty conditional looks like this:

{% if not formPost %}
  show map based on ip location or default if invalid province detected
{% else %}
  show map based on user input of city/postal code and/or province
{% endif %

The final map is rendered with the following code. The parameter allStores is set in various places within the conditional above and nested conditionals within that one.

 {{ craft.smartMap.map(allStores, options) }}

IP Location Based Map

This next code is in the first half of the above conditional. The first thing we do is get all possible results from the store channel. Then there's a conditional that checks if the visitor is located in a valid province. If they are then it displays all results in that province. If they are not then the map will output all locations across Canada.

{# no filter find all stores, this is before searching or filtering based on ip #}

{% set allStores = craft.entries.section('stores').limit(null).find() %}

{#Set find results based on ip location #}
{% if visitor and (visitor.state in validProvinces) %}

  {# add geo location to map on landing without form submission #}

  {% set components = {'country': 'CA', 'administrative_area' : 'craft.smartMap.visitor.state'} %}
  {% set params = {
    target: craft.smartMap.visitor.state,
    range: 500,
    units: "kilometers",
    components: components,
  } %}

  {% set allStores = craft.entries.mapAddress(params).order('distance').find() %}

{% else %} {# if no ip or ip location returns no results show default map #}

  {% set allStores = craft.entries.section('stores').limit(1000).find() %}

{% endif %}

The form used to filter the map

User Filtered Map

The second half of our conditional checking  if the form has not been submitted renders the google map based on user filtering from the form. This consists of three checks:

  1. If province - display results if the province is selected
  2. If query - display results based on user input of city or postal code
  3. If province and query - get results for the province and limit to within 100km of the query.

Setting Variables Again

Before we do the three conditionals mentioned above we need to set some variables. The first is our target of Canada and a ridiculously large range of 30,000 kilometers. This will only be used if there are no results for the filtering. Then we get all stores and set the country to Canada. Next we set a variable of desiredProvince equal to the get variable of province. Finally we set an empty array of filteredByProvince

{% set params = {
  target: 'Canada',
  range:  30000,
  units:  'kilometers'
} %}

{# get all stores #}
{% set entries = craft.entries.mapAddress(params).order('distance').find() %}

{# search filters via post variable #}
{% set components = {'country': 'CA'} %}

{% set desiredProvince = province %}
{% set filteredByProvince = [] %}

If Province

In this block we run through all of the store entries and find only those where the province set in the mapAddress field matches the province chosen from the select field in the form by the user. Those entries are then added to the filteredByProvince array and we finally set the allStores variable equal to filteredByProvince. This will output all stores in the desired province.

{# if province show only results from that province #}
{% if province %}

  {% for entry in entries %}

    {% if entry.mapAddress.state == desiredProvince %}
       {% set filteredByProvince = filteredByProvince|merge([entry]) %}
    {% endif %}

  {% endfor %}

{% set allStores = filteredByProvince %}

If Query

In this block of code we check if a city or postal code has been submitted and filter the results to within 100km distance of the query.

After that is done we do a check to see if the allStores array is empty  and if it is output a no results message and a map of every store across Canada.

{% if query %}{# begin query check #}

  {% set components = {'country': 'CA'} %}
  {% set params = {
    target: query,
    range: 100,
    units: "kilometers",
    components: components,
  } %}

  {% set allStores = craft.entries.mapAddress(params).order('distance').find() %}

  {# check if there are no results on the query #}
  {% if allStores is empty %}
    <p>No results found for <strong>{{ query }}</strong>. Here is a map of all our stores.</p>

    {% set defaultParams = {
      target: 'Canada',
      range: 30000,
      units: "kilometers",
      components: components,
    } %}

    {% set options = options | merge({center: {
        'lat':findAStore.mapCenterLatitude,
        'lng':findAStore.mapCenterLongitude
      }, 'zoom':findAStore.zoomLevel}) %}

    {% set allStores = craft.entries.mapAddress(defaultParams).order('distance').find() %}

  {% endif %}
  {# end check for no results #}

{% endif %}

If Province and Query

Finally we check if both a query and a province were submitted via the form. The results are limited to the selected province and then filtered to within 100km of the selected query. The first bit of the code is copied from the if province  above however we have set the target in the params variable to be equal to the query submitted.

Once again we check to see if there are results based on the query and province and if there are output results within 100km of the query. If there are no results a map of Canada with all stores is rendered.

{# when both query and province exist do this #}
{% if province and query %}

  {#
    limit results using code from conditional above on province
    and filter based on query
  #}

  {% set params = {
      target: query,
      range:  100,
      units:  'kilometers'
  } %}
  {% set entries = craft.entries.mapAddress(params).order('distance').find() %}

  {# copied from above #}
  {% set desiredProvince = province %}
  {% set filteredByProvince = [] %}

       {% for entry in entries %}

         {% if entry.mapAddress.state == desiredProvince %}
            {% set filteredByProvince = filteredByProvince|merge([entry]) %}
         {% endif %}

       {% endfor %}
  {# end copied #}

  {% if filteredByProvince is empty %}{# check if anything matches combined search #}

    <p>No results found for <strong>{{ query }}</strong> in <strong>{{ p }}</strong>. Here is a map of all our stores.</p>

    {% set defaultParams = {
      target: 'Canada',
      range: 30000,
      units: "kilometers",
      components: components,
    } %}

    {% set options = options | merge({center: {
        'lat':findAStore.mapCenterLatitude,
        'lng':findAStore.mapCenterLongitude
      }, 'zoom':findAStore.zoomLevel}) %}

    {% set allStores = craft.entries.mapAddress(defaultParams).order('distance').find() %}

  {% else %}

    {# output combined results #}
    {% set allStores = filteredByProvince %}

  {% endif %}

{% endif %}

Conclusion

After presenting the final results my client is very happy with it as all the functionality they were looking for works exactly as expected. The next step is to add in HTML5 geolocation which the user would initiate by clicking the location icon next to Find a Store in the screenshot above. This is not urgent as the ip location is working great and is more accurate than expected.

Being able to build a store locator with only one plugin (Smart Map) with searching and location detection is truly amazing. In a similar build on a different CMS a commercial search addon was required. It worked well but I do prefer to keep my builds as native as possible.