Storyblok is the first headless CMS that works for developers & marketers alike.
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 store content type
Let's begin with creating our content structure in Storyblok by creating a new content type named story_store.
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.
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.
Next we can add a new store story and enter some 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.
The StoreFinder component is responsible for the overall layout and for integrating all of the separate components for our Store Finder application.
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-importernode-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.
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.
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.
In the following code snippet you can see how we can use the store service in combination with our App component.
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.
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.
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.
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.
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.
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.
In the following code snippets you can see how we can integrate the StoreFinderMap component into the StorFinder component.
After integrating our new StoreFinderMap component into the StoreFinder we also have to make a few adjustments to the styling of our application.
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.
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.
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.
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.
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.
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.
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.
Above you can see the CSS styles necessary to make the list animation work.
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.
First we add a new <span> tag rendering the distance inside of the <template> of the StoreFinderItem component.
Next we have to make the StoreFinderItem component accept a new distance property.
Additionally we add some basic styling for the new <span> tag we've added.
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.
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.
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.