Source: storelocator.js

/**
 * @name Storelocatorjs
 * @version 2.0.0
 * @license GPLv3 for Open Source use or Storelocatorjs Commercial License for commercial use
 * @author: Joris DANIEL aka Yoriiis
 * @description: Storelocatorjs is a fast and lightweight Javascript library for build your own customizable store locator with a minimalist theme. The cloud function is included to handle store filter requests.
 * {@link https://yoriiis.github.io/storelocatorjs}
 * @copyright 2019 Joris DANIEL aka Yoriiis <https://yoriiis.github.io/storelocatorjs>
 */

'use strict'

import templateSidebarItemResult from './templates/sidebar-item-result'
import templateInfoWindow from './templates/info-window'
import markerSvg from '../svg/marker.svg'
import defaultOptions from './default-options'

/**
 * storelocatorjs
 * @module storelocatorjs
 */
export default class Storelocator {
	/**
	 * Instanciate the constructor
	 * @constructor
	 * @param {Object} options Storelocatorjs options
	 * @param {Function} onReady Callback function executed when the store locator is ready
	 */
	constructor ({options, onReady}) {
		this.options = this.extend(true, defaultOptions, options)
		this.onReady = onReady
		this.isLoading = false
		this.mapHasRequest = false

		if (this.options.webServiceUrl === '') {
			throw new Error('storelocatorjs :: webServiceUrl is empty')
		}

		this.cacheSelectors()
		this.buildLoader()
		this.markerStyles = this.getMarkerStylesByCategory()

		window.googleMapLoaded = () => {
			if (this.options.geolocation.status) {
				this.initGeolocation()
			}

			this.initMap()
			this.addGoogleMapsEvents()
			this.initAutocomplete()
		}

		this.loadAPI(this.options.apiKey)
		this.addEvents()
	}

	/**
	 * Cache DOM selectors
	 */
	cacheSelectors () {
		this.containerStorelocator = document.querySelector(this.options.selectors.container)
		this.formSearch = this.containerStorelocator.querySelector(this.options.selectors.formSearch)
		this.inputSearch = this.containerStorelocator.querySelector(this.options.selectors.inputSearch)
		this.searchFilters = [...this.containerStorelocator.querySelectorAll(this.options.selectors.searchFilters)]
		this.nav = this.containerStorelocator.querySelector(this.options.selectors.nav)
		this.sidebar = this.containerStorelocator.querySelector(this.options.selectors.sidebar)
		this.sidebarResults = this.containerStorelocator.querySelector(this.options.selectors.sidebarResults)
		this.geolocButton = this.containerStorelocator.querySelector(this.options.selectors.geolocButton)
	}

	/**
	 * Build the loader
	 */
	buildLoader () {
		this.loader = this.containerStorelocator.querySelector(this.options.selectors.loader)
		this.loader.innerHTML = `
			<div class="storelocator-loaderBar"></div>
			<div class="storelocator-loaderBar"></div>
			<div class="storelocator-loaderBar"></div>`
	}

	/**
	 * Load the Youtube API
	 * @param {String} apiKey Youtube API key
	 */
	loadAPI (apiKey) {
		let script = document.createElement('script')
		script.async = true
		script.type = 'text/javascript'
		script.src = `https://maps.googleapis.com/maps/api/js?key=${apiKey}&callback=window.googleMapLoaded&libraries=places`
		document.getElementsByTagName('body')[0].appendChild(script)
	}

	/**
	 * Create event listeners
	 */
	addEvents () {
		// Event listener on sidebar result items
		this.sidebarResults.addEventListener('click', this.onClickSidebarResultItem.bind(this))

		// Event listeners on sidebar navigation items
		let buttons = [...this.nav.querySelectorAll('[data-switch-view]')]
		buttons.forEach(button => {
			button.addEventListener('click', this.onClickSidebarNav.bind(this))
		})

		// Event listener on search form
		// Prevent native form submit, managed by autocomplete
		this.formSearch.addEventListener('submit', (e) => {
			e.preventDefault()
		})

		// Event listener on search form filters
		this.searchFilters.forEach(filter => {
			filter.addEventListener('change', this.onChangeSearchFormFilter.bind(this))
		})

		this.geolocButton.addEventListener('click', this.onClickGeolocationButton.bind(this))
	}

	/**
	 * Create Google Maps event listeners
	 */
	addGoogleMapsEvents () {
		// Event listener on search form input
		window.google.maps.event.addDomListener(this.inputSearch, 'keydown', function (e) {
			if (e.keyCode === 13) {
				e.preventDefault()
			}
		})
	}

	/**
	 * Update the loader status
	 * @param {Boolean} state Status of the loader
	 */
	loading (state) {
		if (state) {
			this.loader.classList.add('active')
			this.isLoading = true
		} else {
			// Wait a moment to show the loader
			setTimeout(() => {
				this.loader.classList.remove('active')
				this.isLoading = false
			}, 1050)
		}
	}

	/**
	 * Initialize the Google Maps
	 */
	initMap () {
		// Create global variables for Google Maps
		this.overlayGlobal = null
		this.overlayLimit = null
		this.markers = []
		this.currentInfoWindow = null
		this.infoWindowOpened = false
		this.boundsChangeTimer = null

		this.serviceDistanceMatrix = new window.google.maps.DistanceMatrixService()
		this.boundsGlobal = new window.google.maps.LatLngBounds()
		this.currentRadius = this.options.requests.searchRadius

		if (this.options.markersUpdate.status) {
			this.boundsWithLimit = new window.google.maps.LatLngBounds()
		}

		this.infoWindow = new window.google.maps.InfoWindow()
		this.geocoder = new window.google.maps.Geocoder()
		this.searchData = {
			position: null
		}

		// Set default geolocation datas
		this.geolocationData = {
			userPositionChecked: false,
			marker: null,
			position: null
		}

		// Clone object before to prevent reference
		let mapOptions = this.extend(true, {}, this.options.map.options)

		mapOptions.center = new window.google.maps.LatLng(mapOptions.center[0], mapOptions.center[1])

		// Init Google Maps API with options
		const googleMapsCanvas = this.containerStorelocator.querySelector('#storelocator-googleMapsCanvas')
		this.map = new window.google.maps.Map(googleMapsCanvas, mapOptions)

		if (typeof window.MarkerClusterer !== 'undefined') {
			if (this.options.cluster.status) {
				// Clone object before to prevent reference
				let cloneClusterOptions = this.extend(true, this.options.cluster.options)
				this.markerCluster = new window.MarkerClusterer(this.map, this.markers, cloneClusterOptions)
			}
		}

		// Detect zoom changed and bounds changed to refresh marker on the map
		if (this.options.markersUpdate.status) {
			this.map.addListener('bounds_changed', () => {
				// Prevent multiple event triggered when loading and infoWindow opened
				if (!this.isLoading && !this.infoWindowOpened) {
					this.boundsChanged()
				}
			})
		}

		// Called the user callback if available
		if (typeof this.onReady === 'function') {
			this.onReady(this.map)
		}
	}

	/**
	 * Initialize the user geolocation
	 */
	initGeolocation () {
		// Check the browser support
		if (navigator.geolocation) {
			// Start geolocation on page load
			if (this.options.geolocation.startOnLoad) {
				if (window.location.protocol === 'https:') {
					this.checkUserPosition()
				}
			}
		}
	}

	/**
	 * On click on geolocation button
	 * @param {Object} e Event listener datas
	 */
	onClickGeolocationButton (e) {
		e.preventDefault()
		if (navigator.geolocation) {
			this.loading(true)
			this.checkUserPosition()
		}
	}

	/**
	 * Click on sidebar navigation item
	 * @param {Object} e Event listener datas
	 */
	onClickSidebarNav (e) {
		let mapView = this.containerStorelocator.querySelector('.storelocator-googleMaps')

		e.preventDefault()

		this.nav.querySelector('.active').classList.remove('active')
		e.target.parentNode.classList.add('active')

		if (e.target.getAttribute('data-target') === 'map') {
			mapView.classList.add('active')
			this.sidebar.classList.remove('active')
			window.google.maps.event.trigger(this.map, 'resize')
		} else {
			this.sidebar.classList.add('active')
			mapView.classList.remove('active')
		}
	}

	/**
	 * On change on search form filters
	 * @param {Object} e Event listener datas
	 */
	onChangeSearchFormFilter (e) {
		// Not filters if there is not search value
		if (!this.mapHasRequest) return false

		this.triggerRequest({
			lat: this.searchData.lat,
			lng: this.searchData.lng,
			fitBounds: true
		})
	}

	/**
	 * On click on sidebar result item
	 * @param {Object} e Event listener datas
	 */
	onClickSidebarResultItem (e) {
		if (e.target && e.target.parentNode.classList.contains('store-center-marker-js')) {
			e.preventDefault()

			let currentLink = e.target.parentNode
			let currentMarker = this.markers[currentLink.getAttribute('data-marker-index')]

			this.map.panTo(currentMarker.getPosition())
			this.map.setZoom(16)
			this.openInfoWindow(currentMarker)
			this.containerStorelocator.querySelector('[data-switch-view][data-target="map"]').click()
			window.google.maps.event.trigger(this.map, 'resize')
		}
	}

	/**
	 * Check user position with Google Maps geolocation API
	 * Get the user current position if available
	 */
	checkUserPosition () {
		navigator.geolocation.getCurrentPosition(position => {
			let lat = position.coords.latitude
			let lng = position.coords.longitude
			let markerGeoloc = null
			let positionGeoloc = new window.google.maps.LatLng(lat, lng)

			let options = {
				position: positionGeoloc,
				map: this.map
			}

			// Disable SVG for IE, they don't works
			if (!this.isBrowserIE()) {
				options.icon = this.options.map.markers.styles.length ? this.getIconMarkerByCategory('userPosition').url : ''
			}

			markerGeoloc = new window.google.maps.Marker(options)

			// Store geolocation data
			this.geolocationData.userPositionChecked = true
			this.geolocationData.position = positionGeoloc
			this.geolocationData.marker = markerGeoloc

			if (this.inputSearch.value !== '') {
				this.inputSearch.value = ''
			}

			this.triggerRequest({
				lat: lat,
				lng: lng
			})
		}, response => {
			this.loading(false)
		})
	}

	/**
	 * Function called on user map moved event
	 */
	boundsChanged () {
		if (this.markers.length) {
			clearTimeout(this.boundsChangeTimer)
			this.boundsChangeTimer = setTimeout(() => {
				let listMarkerIndexInViewport = []

				this.markers.forEach((marker, index) => {
					if (marker.getVisible() && this.map.getBounds().contains(marker.getPosition())) {
						listMarkerIndexInViewport.push(index)
					}
				})

				// If map has no markers visible, change map position
				if (listMarkerIndexInViewport.length === 0) {
					this.refreshMapOnBoundsChanged({
						updatePosition: true
					})
				} else if (listMarkerIndexInViewport.length === this.markers.length) {
					// If user see already all markers, zoom is too small, increase it until maxRadius
					if (this.currentRadius < this.options.markersUpdate.maxRadius) {
						this.refreshMapOnBoundsChanged({
							increaseRadius: true
						})
					}
				}
			}, 600)
		}
	}

	/**
	 * Refresh the map on user map moved
	 * Trigger a request with the new map position
	 * @param {Object} options Options to refresh the map
	 */
	refreshMapOnBoundsChanged ({ updatePosition, increaseRadius }) {
		let lat = 0
		let lng = 0

		// If user move on the map and discover area without stores, update markers, with map center position
		if (updatePosition) {
			lat = this.map.getCenter().lat()
			lng = this.map.getCenter().lng()
		} else if (increaseRadius) {
			// Get lat/lng from searchData
			({lat, lng} = this.searchData)

			// Increase currentRadius
			this.currentRadius = this.currentRadius + this.options.markersUpdate.stepRadius
		}

		this.triggerRequest({
			lat: lat,
			lng: lng,
			fitBounds: false // Prevent fitBounds when bounds changed (move or zoom)
		})
	}

	/**
	 * Initialize Google Maps Autocomplete
	 * @documentation https://developers.google.com/maps/documentation/javascript/places-autocomplete
	 */
	initAutocomplete () {
		let autocomplete = new window.google.maps.places.Autocomplete(this.inputSearch, {})

		this.inputSearch.focus()
		autocomplete.bindTo('bounds', this.map)

		autocomplete.addListener('place_changed', () => {
			this.loading(true)
			let place = autocomplete.getPlace()

			if (place.geometry) {
				this.autocompleteRequest({
					lat: place.geometry.location.lat(),
					lng: place.geometry.location.lng()
				})
			} else {
				this.geocoder.geocode({
					'address': place.name
				}, (results, status) => {
					if (status === window.google.maps.GeocoderStatus.OK) {
						this.autocompleteRequest({
							lat: results[0].geometry.location.lat(),
							lng: results[0].geometry.location.lng()
						})
					}
				})
			}
		})
	}

	/**
	 * Function called on autocomplete changes
	 * Trigger a request with the new user research
	 * @param {String} lat Latitude of the research
	 * @param {String} lng Longitude of the research
	 */
	autocompleteRequest ({lat, lng}) {
		this.userPositionChecked = false

		// Reset currentRadius on new search
		this.currentRadius = this.options.requests.searchRadius

		this.triggerRequest({
			'lat': lat,
			'lng': lng
		})
	}

	/**
	 * Trigger a request to the web service to get all store results
	 * according to the position (lat, lng)
	 * @param {String} lat Latitude of the research
	 * @param {String} lng Longitude of the research
	 * @param {Boolean} fitBounds Fit bounds on the map
	 */
	triggerRequest ({lat, lng, fitBounds = true}) {
		this.mapHasRequest = true
		this.loading(true)

		let requestDatas = this.serializeForm({
			lat: lat,
			lng: lng
		})

		// Update search data stored
		this.searchData.lat = lat
		this.searchData.lng = lng
		this.searchData.position = new window.google.maps.LatLng(lat, lng)

		// Fecth configuration
		let fetchConf = {
			method: 'POST',
			headers: {
				'Content-Type': 'application/json'
			},
			body: JSON.stringify(requestDatas)
		}

		// Fecth store datas from the web service
		fetch(this.options.webServiceUrl, fetchConf)
			.then(response => {
				if (!response.ok) {
					throw new Error(response)
				}
				return response
			})
			.then(res => res.json())
			.then(jsonResponse => {
				let data = jsonResponse

				if (data !== null) {
					this.parseStores({
						stores: data,
						fitBounds: fitBounds
					})
				}
			})
			.catch(error => {
				this.loading(false)
				throw new Error(error)
			})
	}

	/**
	 * Serialize form datas
	 * @param {String} lat Latitude
	 * @param {String} lng Longitude
	 * @return {Object} formData Datas required for the request (lat, lng, storesLimit, input, categories, radius)
	 */
	serializeForm ({lat = false, lng = false}) {
		let formDatas = {}
		let categories = []

		// Get all selected categories
		this.searchFilters.forEach((filter, index) => {
			if (filter.checked) {
				categories.push(filter.value)
			}
		})
		formDatas.categories = categories

		if (lat && lng) {
			formDatas.lat = lat
			formDatas.lng = lng
		}

		formDatas.radius = this.currentRadius
		formDatas.limit = this.options.requests.storesLimit

		return formDatas
	}

	/**
	 * Parse store datas from the web service
	 * Create all markers
	 * Create all store results
	 * @param {Object} options Store datas from the web service
	 */
	parseStores (options) {
		let noResult = true
		let {stores} = options
		let {fitBounds} = options
		let hmlListResult = `
			<p class="storelocator-sidebarIntro">
				${stores.length} results sorted by distance correspond to your research
			</p>
			<ul class="storelocator-sidebarResultsList">`

		// Destroy old markers before parse new stores
		this.destroyMarkers()

		// Reset infoWindow status
		this.infoWindowOpened = false

		// Re-declare bounds on new research, it's important else zoom bug after one request
		this.boundsGlobal = new window.google.maps.LatLngBounds()

		if (this.options.markersUpdate.status) {
			this.boundsWithLimit = new window.google.maps.LatLngBounds()
		}

		// If geolocation enabled, add geolocation marker to the list and extend the bounds global
		if (this.geolocationData.userPositionChecked) {
			this.markers.push(this.geolocationData.marker)
			this.boundsGlobal.extend(this.geolocationData.position)
		}

		// Get lat/lng from searchData
		let origin = this.searchData.position

		// Loop on all stores by category
		stores.forEach((store, index) => {
			noResult = false
			store.index = index
			store.position = new window.google.maps.LatLng(store.lat, store.lng)

			this.boundsGlobal.extend(store.position)
			this.createMarkers(store)

			hmlListResult += templateSidebarItemResult.call(this, {
				store: store,
				origin: origin
			})
		})

		hmlListResult += `</ul>`

		// If no result, show error message and center map on current country
		if (noResult) {
			this.sidebarResults.innerHTML = `
				<p class="storelocator-sidebarNoResults">
					No results for your request.<br />
					Please try a new search with differents choices.
				</p>`
			if (this.overlayLimit !== null) {
				this.overlayLimit.setMap(null)
			}
			if (this.overlayGlobal !== null) {
				this.overlayGlobal.setMap(null)
			}
		} else {
			this.sidebarResults.innerHTML = hmlListResult

			// Add all maskers to cluster if option is enabled
			if (typeof MarkerClusterer !== 'undefined') {
				if (this.options.cluster.status) {
					this.markerCluster.addMarkers(this.markers)
				}
			}

			// Create custom bounds with limit viewport, no fitBounds the boundsGlobal
			if (this.options.markersUpdate.status) {
				this.createViewportWithLimitMarker({
					stores: stores,
					fitBounds: fitBounds
				})
			} else {
				// Else, and if requested, fitbounds the boundsGlobal
				if (fitBounds) {
					this.map.fitBounds(this.boundsGlobal)
				}
			}
		}

		this.loading(false)
	}

	/**
	 * Create a custom viewport (boundsWithLimit)
	 * Display a minimal list of markers according to the limitInViewport option
	 * @param {Object} options Datas to create the custom viewport
	 */
	createViewportWithLimitMarker (options) {
		let {stores} = options
		let maxMarkersInViewport = this.options.markersUpdate.limitInViewport
		let maxLoop = (stores.length < maxMarkersInViewport) ? stores.length : maxMarkersInViewport

		// If geolocation enabled, add geolocation marker to the list and extend the bounds limit
		if (this.geolocationData.userPositionChecked) {
			this.boundsWithLimit.extend(this.geolocationData.position)
		}

		for (let i = 0; i < maxLoop; i++) {
			this.boundsWithLimit.extend(stores[i].position)
		}

		if (options.fitBounds) {
			this.map.fitBounds(this.boundsWithLimit)
		}

		if (this.options.debug) {
			this.createOverlays()
		}
	}

	/**
	 * Create custom overlay on the map for the debug mode
	 * overlayGlobal: list of all stores according to maxRadius option
	 * overlayLimit: list of all stores according to the limitInViewport option
	 */
	createOverlays () {
		if (this.overlayGlobal !== null) {
			this.overlayGlobal.setMap(null)
		}
		this.overlayGlobal = new window.google.maps.Rectangle({
			bounds: this.boundsGlobal,
			strokeColor: null,
			strokeOpacity: 0,
			fillColor: '#FF0000',
			fillOpacity: 0.35,
			map: this.map
		})

		if (this.overlayLimit !== null) {
			this.overlayLimit.setMap(null)
		}
		this.overlayLimit = new window.google.maps.Rectangle({
			bounds: this.boundsWithLimit,
			strokeColor: null,
			strokeOpacity: 0,
			fillColor: '#54ff00',
			fillOpacity: 0.35,
			map: this.map
		})
	}

	/**
	 * Open the Google Maps native InfoWindow
	 * @param {Object} currentMarker Marker data display inside the infoWindow
	 */
	openInfoWindow (currentMarker) {
		// Get lat/lng from searchData
		let origin = this.searchData.position

		let hmlinfoWindow = templateInfoWindow({
			store: currentMarker.store,
			origin: origin
		})

		this.infoWindow.setContent(hmlinfoWindow)

		// Change infoWindow status
		window.google.maps.event.addListener(this.infoWindow, 'closeclick', () => {
			this.infoWindowOpened = false
		})

		// Close previous infoWindow open
		if (this.currentInfoWindow !== null) {
			this.currentInfoWindow.close()
		}

		// Store marker info for next infoWindow
		this.currentInfoWindow = this.infoWindow

		// Open infoWindow
		this.infoWindow.open(this.map, currentMarker)
	}

	/**
	 * Destroy all created Google Map markers
	 */
	destroyMarkers () {
		// Destroy all maskers references in cluster is enabled
		if (typeof MarkerClusterer !== 'undefined') {
			if (this.options.cluster.status) {
				this.markerCluster.clearMarkers()
			}
		}

		// Loop backwards on markers and remove them
		for (let i = this.markers.length - 1; i >= 0; i--) {
			let currentMarker = this.markers[i]

			// Remove listener from marker instance
			window.google.maps.event.clearInstanceListeners(currentMarker)

			// Remove the marker
			currentMarker.setMap(null)
		}

		this.markers = []
	}

	/**
	 * Create a Google Maps markers
	 * @param {Object} data Marker datas
	 */
	createMarkers (data) {
		let markerStyle = this.markerStyles[data.category]
		let options = {
			position: data.position,
			map: this.map,
			optimized: true,
			label: {
				text: (data.index + 1).toString(),
				fontFamily: 'inherit',
				fontSize: '13px',
				fontWeight: '500',
				color: markerStyle ? markerStyle.colorText : '#000'
			}
		}

		// Disable SVG for IE, they don't works
		if (!this.isBrowserIE()) {
			options.icon = this.options.map.markers.styles.length ? this.getIconMarkerByCategory(data.category) : ''
		}

		let marker = new window.google.maps.Marker(options)

		// Push marker data in marker
		marker.store = data

		this.markers.push(marker)

		// Click on marker to show infoWindow
		window.google.maps.event.addListener(marker, 'click', () => {
			this.infoWindowOpened = true
			this.openInfoWindow(marker)
		})
	}

	/**
	 * Get marker styles by category, from options
	 * @return {Object} Formatted object with category name into key and marker styles datas
	 */
	getMarkerStylesByCategory () {
		let styles = {}
		this.options.map.markers.styles.forEach((marker) => {
			styles[marker.category] = {
				colorBackground: marker.colorBackground,
				colorText: marker.colorText
			}
		})
		return styles
	}

	/**
	 * Get SVG icon by category styles
	 * @param {String} category Marker category
	 * @return {Object} Icon datas to generate a Google Maps markers
	 */
	getIconMarkerByCategory (category) {
		let offsetXLabel = (this.options.map.markers.width / 2) - 0.9
		let offsetYLabel = (this.options.map.markers.height / 2) - 3
		let colorBackground = this.markerStyles[category] ? this.markerStyles[category].colorBackground : '#E5454C'

		return {
			url: this.generateSVG({
				colorBackground: colorBackground,
				width: this.options.map.markers.width,
				height: this.options.map.markers.height
			}),
			labelOrigin: new window.google.maps.Point(offsetXLabel, offsetYLabel)
		}
	}

	/**
	 * Generate SVG from the associated SVG file
	 * @param {Object} Style datas to customize the SVG
	 * @return {Object} Custom SVG to generate a Google Maps marker icons
	 */
	generateSVG (options) {
		// Create DOMParser from SVG string
		const parser = new DOMParser()
		let parserSvg = parser.parseFromString(markerSvg, 'text/html')
		let elementMarkerSvg = parserSvg.querySelector('svg')

		// Change SVG attributes
		elementMarkerSvg.setAttribute('width', `${options.width}px`)
		elementMarkerSvg.setAttribute('height', `${options.height}px`)
		elementMarkerSvg.querySelectorAll('path').forEach(path => {
			path.setAttribute('fill', options.colorBackground)
		})

		// Create Serializer from element
		const serializer = new XMLSerializer()
		var stringMarkerSvg = serializer.serializeToString(elementMarkerSvg)

		// Format SVG to generate an icon on the map
		let customSVG = {
			mimetype: 'data:image/svg+xml;base64,',
			scaledSize: new window.google.maps.Size(options.width, options.height)
		}
		return customSVG.mimetype + btoa(stringMarkerSvg)
	}

	/**
	 * Check if browser is an old Internet Explorer
	 */
	isBrowserIE () {
		return !!((document.documentMode && document.documentMode >= 9))
	}

	/**
	 * Extends multiple object into one
	 * @param {Boolean} deep Enable extend for deep object properties
	 * @param {Array} objects List of objects to merged
	 * @return {Object} Objects merged into one
	 */
	extend (deep = false, ...objects) {
		let extended = {}

		// Merge the object into the extended object
		let merge = obj => {
			for (let prop in obj) {
				if (Object.prototype.hasOwnProperty.call(obj, prop)) {
					// If deep merge and property is an object, merge properties
					if (deep && Object.prototype.toString.call(obj[prop]) === '[object Object]') {
						extended[prop] = this.extend(true, extended[prop], obj[prop])
					} else {
						extended[prop] = obj[prop]
					}
				}
			}
		}

		// Loop through each object and conduct a merge
		objects.forEach(object => {
			merge(object)
		})

		return extended
	}
}