Building a Store Finder with Storyblok and Vue.js

Contents

When we build websites for companies with several stores, it is often desirable to make it as easy as possible for the users to find their nearest location. In this article you’ll learn how to build a Store Finder featuring a Google Map and a search functionality for stores nearby. We will use Storyblok as our headless CMS of choice to provide the location data and Vue.js is used to build the Store Finder application itself.

The final result: a list of stores sorted by distance and a map zoomed to the nearest store.

The store content type

Let’s begin with creating our content structure in Storyblok by creating a new content type named story_store.

Creating the store content type in Storyblok.

Because I use the approach I’ve described in my article about how to structure content as Stories, Chapters and Paragraphs with Storyblok we prefix the content type with story. So we have to make sure to use Store as its display name because otherwise the auto generated name would be Story Store. We don’t want the new content type to be nestable.

Now we can edit the schema of our newly created content type. We add two new fields address and opening_hours. Both of those fields are custom plugins which you have to install first.

Adding an address field.

You can read more about the address field and how to install it in my previous article about this topic.

Adding an opening hours field.

I’ve also written an article about how to build an opening hours plugin, checkout the article to learn how to install this plugin.

Adding new stores

Our store content type is ready now and we can start to add some locations. But first, we add a new folder for our stores. This makes it more convenient to manage the store locations in the future.

Adding a new folder for store stories.

Next we can add a new store story and enter some data.

Adding a new store.

Entering the store data.

Basic project setup

We build our Store Finder application on top of a basic Vue CLI 3.0 setup. If you want to read more about how to set up a Vue.js powered SPA from the ground up I highly recommend you to head over to the official documentation and come back afterwards.

If you want to view the entire code right away, instead of following this step-by-step guide, you can also check out this GitHub repository.

The StoreFinder component

Let’s begin with laying out the basic application structure for our Google Maps and Storyblok powered Store Finder. First we create a new file src/components/StoreFinder.vue.

<template>
  <div class="StoreFinder">
    <div class="StoreFinder__search">
      <!-- StoreFinderSearch -->
    </div>
    <div class="StoreFinder__grid">
      <div class="StoreFinder__list-wrap">
        <!-- StoreFinderList -->
      </div>
      <div class="StoreFinder__map-wrap">
        <!-- StoreFinderMap -->
      </div>
    </div>
  </div>
</template>

<script>
export default {
  name: 'StoreFinder',
  props: {
    stores: {
      default: () => [],
      required: true,
      type: Array,
    },
  },
};
</script>

The StoreFinder component is responsible for the overall layout and for integrating all of the separate components for our Store Finder application.

<template>
  <div class="App o-container">
    <h1 class="App__headline">
      Store Finder
    </h1>
    <StoreFinder
      v-if="stores.length"
      :stores="stores"
      class="App__store-finder"
    />
  </div>
</template>

<script>
import StoreFinder from './components/StoreFinder.vue';

export default {
  name: 'App',
  components: {
    StoreFinder,
  },
  data() {
    return {
      stores: [],
    };
  },
};
</script>

<style lang="scss">
@import './assets/scss/settings/**/*';
@import './assets/scss/generic/**/*';
@import '{
  .o-container,
} from ~@avalanche/object-container';

.App {
  padding-top: setting-spacing(xl);
  padding-bottom: setting-spacing(xl);
}

.App__headline {
  text-align: center;
}

.App__store-finder {
  margin-top: setting-spacing(xxl);
}
</style>

Above you can see that we’ve added our newly created component to the core src/App.vue component. You might notice the usage of glob patterns **/* and the special @import { .o-container } from ~@avalanche/object-container' import syntax in the <style> section. This is made possible by the node-sass-magic-importer node-sass custom importer function. But that’s not particular important for the functionality of this application.

The data() property of our component provides an empty array by default. Let’s move on and see how we can fetch the data for the stores we previously added in the Storyblok app.

The Storyblok API util

Storyblok provides us with a npm package which we can use to easily connect to the Storyblok API.

npm install storyblok-js-client

After installing the storyblok-js-client dependency via npm we can use it inside of our application. In order to fetch data from Storyblok we have to initialize a new StoryblokClient instance.

// src/utils/storyblok.js
import StoryblokClient from 'storyblok-js-client';

// This is the place for your personal access
// token which you can find in the settings
// section of your Storyblok space.
const ACCESS_TOKEN = 'E4hKZiPPl2OZ6ErnofrW2Att';

export default new StoryblokClient({
  accessToken: ACCESS_TOKEN,
});

Storyblok API-Keys settings.

The store service

We can use the API utility function from the previous step to fetch the data of the stores we’ve created earlier from Storyblok.

// src/services/store.js
import storyblok from '../utils/storyblok';

// In this example we assume we have <= 100 stores,
// if you have more than that you need to make
// additional API requests to fetch all pages.
const PER_PAGE_MAX = 100;

export async function list(options) {
  const defaultOptions = {
    resolve_links: 1,
    per_page: PER_PAGE_MAX,
  };
  const response = await storyblok.get('cdn/stories', {
    filter_query: {
      // Only fetch stories of type `story_store`.
      component: {
        in: 'story_store',
      },
    },
    ...defaultOptions,
    ...options,
  });

  return response.data.stories;
}

In the following code snippet you can see how we can use the store service in combination with our App component.

 <script>
+import * as storeService from './services/store';
+
 import StoreFinder from './components/StoreFinder.vue';
 
 export default {
   name: 'App',
   components: {
     StoreFinder,
   },
   data() {
     return {
       stores: [],
     };
   },
+  created() {
+    // Initially fetch all stores.
+    this.fetchStores();
+  },
+  methods: {
+    async fetchStores() {
+      this.stores = await storeService.list();
+    },
+  },
 };
 </script>

Once we have integrated the store service into our application, we have all the data we need in order to bring the app to life.

Rendering a list of stores

In the first step, we want to render a list of all of our locations showing their address and opening hours data. Let’s start with the component which is responsible for rendering a single store list item.

<template>
  <li class="StoreFinderItem">
    <address  class="StoreFinderItem__section">
      <div class="StoreFinderItem__section">
        <div class="StoreFinderItem__headline">
          {{ name }}
        </div>
        {{ address.postal_code }}
        {{ address.town }}<br>
        {{ address.street }}
      </div>
      <div
        v-if="address.phone || address.fax"
        class="StoreFinderItem__section"
      >
        <template v-if="address.phone">
          Tel.:
          <a
            v-text="address.phone"
            :href="`tel:${address.phone}`"
          />
          <br>
        </template>
        <template v-if="address.fax">
          Fax: {{ address.fax }}
        </template>
      </div>
    </address>
    <div class="StoreFinderItem__section">
      <OpeningHours :days="openingHours.days"/>
    </div>
    <div class="StoreFinderItem__section">
      <a :href="directionsUrl">
        Directions
      </a>
    </div>
  </li>
</template>

<script>
import OpeningHours from './OpeningHours.vue';

export default {
  name: 'StoreFinderItem',
  components: {
    OpeningHours,
  },
  props: {
    address: {
      default: () => ({}),
      required: true,
      type: Object,
    },
    name: {
      default: '',
      required: true,
      type: String,
    },
    openingHours: {
      default: () => ({}),
      required: true,
      type: Object,
    },
  },
  created() {
    // Create a Google Maps URL,
    // for directions to the shop.
    const url = 'https://www.google.com/maps/dir/?api=1';
    const destination = [
      this.address.street,
      `${this.address.postal_code} ${this.address.town}`,
    ].join(', ');
    this.directionsUrl = `${url}&destination=${encodeURI(destination)}`;
  },
};
</script>

<style lang="scss">
@import '../assets/scss/settings/**/*';

.StoreFinderItem__headline {
  font-weight: bold;
}

.StoreFinderItem__section {
  &:not(:first-child) {
    margin-top: setting-spacing(s);
  }
}
</style>

Let’s quickly walk through the code of the StoreFinderItem component you can see above. First of all, we render all the address data which are passed to the component via the address property. Because some store might not have a phone or fax number, we check if either one or both are available. Next you can see that we include an OpeningHours component. I won’t go into much detail about the implementation of this particular component, if you’re interested in that, you can read more about it in the article about how to build an opening hours Storyblok plugin.

<template>
  <ul class="StoreFinderList">
    <StoreFinderItem
      v-for="store in stores"
      :key="store.id"
      :address="store.content.address"
      :name="store.name"
      :opening-hours="store.content.opening_hours"
      class="StoreFinderList__item"
    />
  </ul>
</template>

<script>
import StoreFinderItem from './StoreFinderItem.vue';

export default {
  name: 'StoreFinderList',
  components: {
    StoreFinderItem,
  },
  props: {
    stores: {
      default: () => [],
      required: true,
      type: Array,
    },
  },
};
</script>

<style lang="scss">
@import '../assets/scss/settings/**/*';

.StoreFinderList__item {
  &:not(:first-child) {
    margin-top: setting-spacing(m);
    padding-top: setting-spacing(m);
    border-top: 1px solid #e0e0e0;
  }
}
</style>

The StoreFinderList component is responsible for rendering a list of StoreFinderItem components. Each StoreFinderItem represents one of our stores. Apart from rendering the stores and applying some very basic styling, not much is going on in this component.

     </div>
     <div class="StoreFinder__grid">
       <div class="StoreFinder__list-wrap">
-        <!-- StoreFinderList -->
+        <StoreFinderList :stores="stores"/>
       </div>
       <div class="StoreFinder__map-wrap">
         <!-- StoreFinderMap -->
 </template>
 
 <script>
+import StoreFinderList from './StoreFinderList.vue';
+
 export default {
   name: 'StoreFinder',
+  components: {
+    StoreFinderList,
+  },
   props: {
     stores: {
       default: () => [],

Now we can use our newly created StoreFinderList component inside of the StoreFinder component in order to render all of our stores as a list. In the two diffs above you can see how we integrate the StoreFinderList into the StoreFinder.

Simple list of stores.

Rendering the stores on a Google Map

Next, we want to render a Google Map showing the exact location of all our stores alongside the list of store addresses and opening hours.

<template>
  <div class="StoreFinderMap"/>
</template>

<script>
import MarkerClusterer from '@google/markerclusterer';

import gmapsInit from '../utils/gmaps';

export default {
  name: 'StoreFinderMap',
  props: {
    stores: {
      default: () => [],
      required: true,
      type: Array,
    },
  },
  async mounted() {
    try {
      this.google = await gmapsInit();
      this.geocoder = new this.google.maps.Geocoder();
      this.map = new this.google.maps.Map(this.$el);

      // Zoom to Europe.
      this.geocoder.geocode({ address: 'Europe' }, (results, status) => {
        if (status !== 'OK' || !results[0]) {
          throw new Error(status);
        }
        this.map.setCenter(results[0].geometry.location);
        this.map.fitBounds(results[0].geometry.viewport);
      });

      // Initialize and cluster markers.
      const markerClickHandler = (marker) => {
        this.map.setZoom(16);
        this.map.setCenter(marker.getPosition());
      };
      const markers = this.stores
        .map((store) => {
          const marker = new this.google.maps.Marker({
            position: {
              lat: store.content.address.latitude,
              lng: store.content.address.longitude,
            },
            map: this.map,
          });
          marker.addListener('click', () => markerClickHandler(marker));
          return marker;
        });

      new MarkerClusterer(this.map, markers, {
        imagePath: 'https://developers.google.com/maps/documentation/javascript/examples/markerclusterer/m',
      });
    } catch (error) {
      // Implement your own error handling here.
      console.error(error);
    }
  },
};
</script>

<style lang="scss">
.StoreFinderMap {
  width: 100%;
  height: 100%;
  min-height: 15em;
}
</style>

Above you can see the implementation of the StoreFinderMap component. After initializing Google Maps with the gmapsInit() utility function, we can create a new map instance with this.google.maps.Map(). If you’re interested in the implementation of gmapsInit() you can find it on GitHub.

After initializing new instances of Geocoder and Map, we use the Geocoder instance to zoom our newly created map to Europe. Next we specify a callback function that should be triggered whenever a marker on our map is clicked. The click handler callback simply zooms the map to the clicked marker. In order to initialize a marker for each of our stores, we iterate over all of the stores and create a new marker for each of it by creating a new marker instance with this.google.maps.Marker().

Last but not least, we create a new MarkerClusterer instance in order to cluster markers that are close to each other. To make this work we need to npm install the @google/markerclusterer package first.

npm install @google/markerclusterer

In the following code snippets you can see how we can integrate the StoreFinderMap component into the StorFinder component.

         <StoreFinderList :stores="stores"/>
       </div>
       <div class="StoreFinder__map-wrap">
-        <!-- StoreFinderMap -->
+        <StoreFinderMap :stores="stores"/>
       </div>
     </div>
   </div>
 <script>
 import StoreFinderList from './StoreFinderList.vue';
+import StoreFinderMap from './StoreFinderMap.vue';
 
 export default {
   name: 'StoreFinder',
   components: {
     StoreFinderList,
+    StoreFinderMap,
   },
   props: {
     stores: {

After integrating our new StoreFinderMap component into the StoreFinder we also have to make a few adjustments to the styling of our application.

@import '../assets/scss/settings/**/*';

.StoreFinder__grid {
  $breakpoint: 42em;

  display: flex;
  border: 1px solid #e0e0e0;
  border-radius: 0.25em;

  @media (max-width: $breakpoint - 0.0625em) {
    flex-direction: column-reverse;
  }

  @media (min-width: $breakpoint) {
    height: 32em;
  }
}

.StoreFinder__list-wrap {
  padding: setting-spacing(m);
  overflow: auto;
  background-color: #fff;
}

.StoreFinder__map-wrap {
  flex-grow: 1;
}

Above you can see the <style> section of the StoreFinder component. The @import '../assets/scss/settings/**/*' statement imports some helper functions for commonly used setting variables like spacings.

Google Map with clustered markers.

Search and sort stores by address

Although we’ve already accomplished a lot, the most important functionality is still missing: searching and sorting stores based on the users location.

<template>
  <form
    class="StoreFinderSearch"
    @submit.prevent="searchAddress"
  >
    <input
      v-model="address"
      placeholder="Enter your address"
      aria-label="Your address"
      class="StoreFinderSearch__input StoreFinderSearch__form-element"
    >
    <button class="StoreFinderSearch__form-element">
      Search store
    </button>
    <button
      type="button"
      class="StoreFinderSearch__form-element"
      @click="searchNearBy"
    >
      Stores near me
    </button>
  </form>
</template>

<script>
import gmapsInit from '../utils/gmaps';

export default {
  name: 'StoreFinderSearch',
  data() {
    return {
      address: null,
    };
  },
  async created() {
    this.google = await gmapsInit();
    this.geocoder = new this.google.maps.Geocoder();
  },
  methods: {
    searchAddress() {
      this.geocoder.geocode({ address: this.address }, (results, status) => {
        if (status !== 'OK' || !results[0]) return;

        // Set address field to the address
        // found by the Google Maps API and
        // emit a search event with the found
        // coordinates.
        this.address = results[0].formatted_address;
        this.$emit('search', {
          latitude: results[0].geometry.location.lat(),
          longitude: results[0].geometry.location.lng(),
        });
      });
    },
    async searchNearBy() {
      const {
        latitude,
        longitude,
      } = await new Promise((resolve, reject) => {
        navigator.geolocation.getCurrentPosition(
          ({ coords }) => resolve(coords),
          // Reject if the user doesn't
          // allow accessing their location.
          error => reject(error),
        );
      });

      const latLng = new this.google.maps.LatLng(latitude, longitude);
      this.geocoder.geocode({ latLng }, (results, status) => {
        if (status !== 'OK' || !results[0]) return;

        // Set address field to the address
        // found by the Google Maps API and
        // emit a search event with the users
        // coordinates.
        this.address = results[0].formatted_address;
        this.$emit('search', {
          latitude: results[0].geometry.location.lat(),
          longitude: results[0].geometry.location.lng(),
        });
      });
    },
  },
};
</script>

<style lang="scss">
@import '../assets/scss/settings/**/*';

$breakpoint: 42em;

.StoreFinderSearch {
  display: flex;

  @media (max-width: $breakpoint - 0.0625em) {
    flex-direction: column;
  }
}

.StoreFinderSearch__form-element {
  padding: setting-spacing(s);
  border: 1px solid #e0e0e0;
  border-radius: 0.25em;

  @media (max-width: $breakpoint - 0.0625em) {
    &:not(:first-child) {
      margin-top: setting-spacing(xs);
    }
  }

  @media (min-width: $breakpoint) {
    &:not(:first-child) {
      margin-left: setting-spacing(xs);
    }
  }
}

.StoreFinderSearch__input {
  flex-grow: 1;
}
</style>

In the code snippet above you can see the new StoreFinderSearch component. This component is responsible for rendering a search form with an input field and two buttons. If the user enters their address into the input field and submits the form by pressing enter or clicking the first button, the searchAddress() method is triggered. The searchAddress() method takes the address string entered by the user and sends it to the Google Maps API to get the corresponding location data. We emit a search event with the found coordinates as its value which we can later use to calculate the nearest store.

If a user clicks the second button, we use the browsers Geolocation API to get the current location of the user and send it to the Google Maps API in order to find out the corresponding address. Again we emit the coordinates as a search event.

 <template>
   <div class="StoreFinder">
     <div class="StoreFinder__search">
-      <!-- StoreFinderSearch -->
+      <StoreFinderSearch @search="currentCoordinates = $event"/>
     </div>
     <div class="StoreFinder__grid">
       <div class="StoreFinder__list-wrap">
-        <StoreFinderList :stores="stores"/>
+        <StoreFinderList :stores="storesOrderedByDistance"/>
       </div>
       <div class="StoreFinder__map-wrap">
-        <StoreFinderMap :stores="stores"/>
+        <StoreFinderMap
+          :stores="storesOrderedByDistance"
+          :current-location="currentCoordinates"
+        />
       </div>
     </div>
   </div>
 </template>
 
 <script>
+import {
+  convertUnit,
+  orderByDistance,
+} from 'geolib';
+
 import StoreFinderList from './StoreFinderList.vue';
 import StoreFinderMap from './StoreFinderMap.vue';
+import StoreFinderSearch from './StoreFinderSearch.vue';
 
 export default {
   name: 'StoreFinder',
   components: {
     StoreFinderList,
     StoreFinderMap,
+    StoreFinderSearch,
   },
   props: {
     stores: {
       default: () => [],
       required: true,
       type: Array,
     },
   },
+  data() {
+    return {
+      currentCoordinates: null,
+    };
+  },
+  computed: {
+    storeCoordinates() {
+      return this.stores.map(store => ({
+        latitude: store.content.address.latitude,
+        longitude: store.content.address.longitude,
+      }));
+    },
+    storesOrderedByDistance() {
+      if (!this.currentCoordinates) return this.stores;
+
+      const orderAndDistance = orderByDistance(
+        this.currentCoordinates,
+        this.storeCoordinates,
+      );
+
+      return orderAndDistance.map(({ distance, key }) => ({
+        ...this.stores[key],
+        distance: convertUnit('km', distance, 1),
+      }));
+    },
+  },
 };
 </script>

This time we have to make some more advanced changes to the StoreFinder component to make the new StoreFinderSearch component work as expected.

We listen to the search event on the StoreFinderSearch component and set the currentCoordinates to the value it emits. This triggers the new storesOrderedByDistance() computed property to update and to return the list of stores ordered by the distance to the currentCoordinates. We also pass this new computed property to the child components instead of directly passing the stores property to them.

We use the geolib package to help us sort stores by distance to specific coordinates. This means we also have to install this package first.

npm install geolib

Next we also want to make some updates to the StoreFinderMap component. We now pass the value of currentLocation as a property to this component. Additionally we watch the value of this property for changes. Every time its value changes the currentLocation() watcher function is triggered and we zoom the map to the location nearest to the coordinates of currentLocation.

 export default {
   name: 'StoreFinderMap',
   props: {
+    currentLocation: {
+      default: () => ({}),
+      type: Object,
+    },
     stores: {
       default: () => [],
       required: true,
       type: Array,
     },
   },
+  watch: {
+    currentLocation() {
+      // Zoom to the nearest store relative
+      // to the current location.
+      const nearestStore = this.stores[0];
+      const { latitude, longitude } = nearestStore.content.address;
+      const latLng = new this.google.maps.LatLng(latitude, longitude);
+      this.geocoder.geocode({ latLng }, (results, status) => {
+        if (status !== 'OK' || !results[0]) return;
+
+        this.map.setCenter(results[0].geometry.location);
+        this.map.fitBounds(results[0].geometry.viewport);
+      });
+    },
+  },
   async mounted() {
     try {

Store Finder list sorted by distance and zoomed to nearest location.

Adding a list sort animation

Now that we have implemented the basic functionality, we can make it even better by adding an animation for sorting the list of stores.

 <template>
-  <ul class="StoreFinderList">
+  <TransitionGroup
+    name="StoreFinderList__item-"
+    tag="ul"
+    class="StoreFinderList"
+  >
     <StoreFinderItem
       v-for="store in stores"
       :key="store.id"
       :address="store.content.address"
       :name="store.name"
       :opening-hours="store.content.opening_hours"
       class="StoreFinderList__item"
     />
-  </ul>
+  </TransitionGroup>
 </template>
 
 <script>

Thanks to the TransitionGroup component which Vue.js provides to us by default, we can add a fancy animation fairly easy. Instead of using an <ul> tag, we use a <TransitionGroup> which renders to an <ul>. Using StoreFinderList__item- as the name of the transition group is a little trick to make the generated CSS classes work with our BEM naming scheme.

 @import '../assets/scss/settings/**/*';
 
 .StoreFinderList__item {
+  transition-duration: 0.3s;
+  transition-property: opacity, transform;
+
   &:not(:first-child) {
     margin-top: setting-spacing(m);
     padding-top: setting-spacing(m);
     border-top: 1px solid #e0e0e0;
   }
 }
+
+.StoreFinderList__item--enter,
+.StoreFinderList__item--leave-to {
+  opacity: 0;
+}
+
+.StoreFinderList__item--leave-active {
+  position: absolute;
+}
 </style>

Above you can see the CSS styles necessary to make the list animation work.

Store sort animation in action.

Showing the distance to the given address

Another small little improvement we can make is to render the distance of the stores to the address the user has entered.

       <div class="StoreFinderItem__section">
         <div class="StoreFinderItem__headline">
           {{ name }}
+          <span
+            v-if="distance"
+            class="StoreFinderItem__distance"
+          >
+            - {{ distance }} km
+          </span>
         </div>
         {{ address.postal_code }}
         {{ address.town }}<br>

First we add a new <span> tag rendering the distance inside of the <template> of the StoreFinderItem component.

       required: true,
       type: Object,
     },
+    distance: {
+      default: null,
+      type: Number,
+    },
     name: {
       default: '',
       required: true,

Next we have to make the StoreFinderItem component accept a new distance property.

     margin-top: setting-spacing(s);
   }
 }
+
+.StoreFinderItem__distance {
+  font-weight: normal;
+  color: #999;
+}
 </style>

Additionally we add some basic styling for the new <span> tag we’ve added.

       v-for="store in stores"
       :key="store.id"
       :address="store.content.address"
+      :distance="store.distance"
       :name="store.name"
       :opening-hours="store.content.opening_hours"
       class="StoreFinderList__item"

Finally, we also have to provide a value for the newly added distance property. We do this inside of the <template> section of the StoreFinderList component.

Sorted stores with distance to given address.

The final result

If you want to see the Store Finder application we’ve built live and in action you can take a look at this demo page.

Wrapping it up

I think this use case is a great example of how a headless CMS enables you to build anything you want instead of limiting your possibilities by forcing you to use a particular plugin and restricting the options for customization as is the case with many traditional content management systems.

Although this is still a very basic implementation of a Store Finder it can serve as a solid starting point for creating your own implementation of it.


More to read...

About the author

Markus Oberlehner

Markus Oberlehner

Markus Oberlehner is a Open Source Contributor and Blogger living in Austria and a Storyblok Ambassador. He is the creator of avalanche, node-sass-magic-importer, storyblok-migrate.