var wpsl = wpsl || {};

wpsl.gmaps = {};

/**
 * This is only used to init the map after the
 * user agreed to load Google Maps in combination
 * with the Borlabs Cookie plugin.
 *
 * @since 2.2.22
 * @returns {void}
 */
function initWpslMap() {
	var mapsLoaded;

	mapsLoaded = setInterval( function() {
		if ( typeof google === 'object' && typeof google.maps === 'object' ) {
			clearInterval( mapsLoaded );

			jQuery( ".wpsl-gmap-canvas" ).each( function( mapIndex ) {
				var mapId = jQuery( this ).attr( "id" );

				wpsl.gmaps.init( mapId, mapIndex );
			});
		}
	}, 500 );
}

jQuery( document ).ready( function( $ ) {
var geocoder, map, directionsDisplay, directionsService, autoCompleteLatLng,
	activeWindowMarkerId, infoWindow, markerClusterer, startMarkerData, startAddress,
	openInfoWindow = [],
	markersArray = [],
    mapsArray = [],
	markerSettings = {},
	directionMarkerPosition = {},
	mapDefaults = {},
	resetMap = false,
	streetViewAvailable = false,
    autoLoad = ( typeof wpslSettings !== "undefined" ) ? wpslSettings.autoLoad : "",
    userGeolocation = {},
    statistics = {
        enabled: ( typeof wpslSettings.collectStatistics !== "undefined" ) ? true : false,
        addressComponents: ''
	};

/** 
 * Set the underscore template settings.
 * 
 * Defining them here prevents other plugins 
 * that also use underscore / backbone, and defined a
 * different _.templateSettings from breaking the 
 * rendering of the store locator template.
 * 
 * @link	 http://underscorejs.org/#template
 * @requires underscore.js
 * @since	 2.0.0
 */	
_.templateSettings = {
	evaluate: /\<\%(.+?)\%\>/g,
	interpolate: /\<\%=(.+?)\%\>/g,
	escape: /\<\%-(.+?)\%\>/g
};

/**
 * Initialize Google Maps with the correct settings.
 *
 * @since   1.0.0
 * @param   {string} mapId    The id of the map div
 * @param   {number} mapIndex Number of the map
 * @returns {void}
 */
wpsl.gmaps.init = function( mapId, mapIndex ) {
    var mapOptions, mapDetails, settings, infoWindow, latLng,
		bounds, mapData, zoomLevel,
		defaultZoomLevel = Number( wpslSettings.zoomLevel ),
        maxZoom = Number( wpslSettings.autoZoomLevel );

	// Get the settings that belongs to the current map.
	settings = getMapSettings( mapIndex );

	/*
	 * This is the value from either the settings page,
	 * or the zoom level set through the shortcode.
	 */
    zoomLevel = Number( settings.zoomLevel );

    /*
     * If they are not equal, then the zoom value is set through the shortcode.
     * If this is the case, then we use that as the max zoom level.
     */
    if ( zoomLevel !== defaultZoomLevel ) {
        maxZoom = zoomLevel;
	}

	// Create a new infoWindow, either with the infobox libray or use the default one.
	infoWindow = newInfoWindow();

    geocoder	      = new google.maps.Geocoder();
    directionsDisplay = new google.maps.DirectionsRenderer();
    directionsService = new google.maps.DirectionsService();

	// Set the map options.
    mapOptions = {
		zoom: zoomLevel,
		center: settings.startLatLng,
		mapTypeId: google.maps.MapTypeId[ settings.mapType.toUpperCase() ],
		mapTypeControl: Number( settings.mapTypeControl ) ? true : false,
		streetViewControl: Number( settings.streetView ) ? true : false,
        gestureHandling: settings.gestureHandling,
		zoomControlOptions: {
			position: google.maps.ControlPosition[ settings.controlPosition.toUpperCase() + '_TOP' ]
		}
	};

    /**
     * When the gestureHandling is set to cooperative and the scrollWheel
     * options is also set, then the gestureHandling value is ingored.
     *
     * To fix this we only include the scrollWheel options when 'cooperative' isn't used.
     */
    if ( settings.gestureHandling !== 'cooperative' ) {
        mapOptions.scrollwheel = Number( settings.scrollWheel ) ? true : false;
    }

	// Get the correct marker path & properties.
	markerSettings = getMarkerSettings();

	map = new google.maps.Map( document.getElementById( mapId ), mapOptions );

	// Check if we need to apply a map style.
	maybeApplyMapStyle( settings.mapStyle );
	
	if ( ( typeof window[ "wpslMap_" + mapIndex ] !== "undefined" ) && ( typeof window[ "wpslMap_" + mapIndex ].locations !== "undefined" ) ) {
		bounds	= new google.maps.LatLngBounds(),
		mapData = window[ "wpslMap_" + mapIndex ].locations;

		// Loop over the map data, create the infowindow object and add each marker.
		$.each( mapData, function( index ) {
			latLng = new google.maps.LatLng( mapData[index].lat, mapData[index].lng );
			addMarker( latLng, mapData[index].id, mapData[index], false, infoWindow );
			bounds.extend( latLng );
		});

		// If we have more then one location on the map, then make sure to not zoom to far.
		if ( mapData.length > 1 ) {
            // Make sure we don't zoom to far when fitBounds runs.
            attachBoundsChangedListener( map, maxZoom );

            // Make all the markers fit on the map.
            map.fitBounds( bounds );
		}

        /*
         * If we need to apply the fix for the map showing up grey because
         * it's used in a tabbed nav multiple times, then collect the active maps.
         *
         * See the fixGreyTabMap function.
         */
        if ( _.isArray( wpslSettings.mapTabAnchor ) ) {
            mapDetails = {
                map: map,
                bounds: bounds,
				maxZoom: maxZoom
            };

            mapsArray.push( mapDetails );
		}
    }

	// Only run this part if the store locator exist and we don't just have a basic map.
	if ( $( "#wpsl-gmap" ).length ) {
		
		if ( wpslSettings.autoComplete == 1 ) {
			activateAutocomplete();
		}
		
		/*
		 * Not the most optimal solution, but we check the useragent if we should enable the styled dropdowns.
		 * 
		 * We do this because several people have reported issues with the styled dropdowns on
		 * iOS and Android devices. So on mobile devices the dropdowns will be styled according 
		 * to the browser styles on that device.
		 */
		if ( !checkMobileUserAgent() && $( ".wpsl-dropdown" ).length && wpslSettings.enableStyledDropdowns == 1 ) {
			createDropdowns();	
		} else {
			$( "#wpsl-search-wrap select" ).show();
			
			if ( checkMobileUserAgent() ) {
				$( "#wpsl-wrap" ).addClass( "wpsl-mobile" );
			} else {
				$( "#wpsl-wrap" ).addClass( "wpsl-default-filters" );
			}
		}

		// Check if we need to autolocate the user, or autoload the store locations.
		if ( !$( ".wpsl-search" ).hasClass( "wpsl-widget" ) ) {
            if ( wpslSettings.autoLocate == 1 ) {
				checkGeolocation( settings.startLatLng, infoWindow );
			} else if ( wpslSettings.autoLoad == 1 ) {
				showStores( settings.startLatLng, infoWindow );
			}
		}

		// Move the mousecursor to the store search field if the focus option is enabled.
		if ( wpslSettings.mouseFocus == 1 && !checkMobileUserAgent() ) {
			$( "#wpsl-search-input" ).focus();
		}

		// Bind store search button.
		searchLocationBtn( infoWindow );

		// Add the 'reload' and 'find location' icon to the map.
		mapControlIcons( settings, map, infoWindow );

		// Check if the user submitted a search through a search widget.
		checkWidgetSubmit();
	}

	// Bind the zoom_changed listener.
	zoomChangedListener();
};

// Only continue if a map is present.
if ( $( ".wpsl-gmap-canvas" ).length ) {
	$( "<img />" ).attr( "src", wpslSettings.url + "img/ajax-loader.gif" );

	/*
	 * The [wpsl] shortcode can only exist once on a page,
	 * but the [wpsl_map] shortcode can exist multiple times.
	 *
	 * So to make sure we init all the maps we loop over them.
	 */
	$( ".wpsl-gmap-canvas" ).each( function( mapIndex ) {
		var mapId = $( this ).attr( "id" );

		wpsl.gmaps.init( mapId, mapIndex );
	});

	/*
	 * Check if we are dealing with a map that's placed in a tab,
	 * if so run a fix to prevent the map from showing up grey.
	 */
	maybeApplyTabFix();
}


/**
 * Activate the autocomplete for the store search.
 * 
 * @since 2.2.0
 * @link https://developers.google.com/maps/documentation/javascript/places-autocomplete
 * @returns {void}
 */
function activateAutocomplete() {
	var input, autocomplete, place,
		options = {};

	// Handle autocomplete queries submitted by the user using the 'enter' key.
	keyboardAutoCompleteSubmit();

    /**
	 * Check if we need to set the geocode component restrictions.
	 * This is automatically included when a fixed map region is
	 * selected on the WPSL settings page.
     */
	if ( typeof wpslSettings.geocodeComponents !== "undefined" && !$.isEmptyObject( wpslSettings.geocodeComponents ) ) {
		options.componentRestrictions = wpslSettings.geocodeComponents;

		/**
		 * If the postalCode is included in the autocomplete together with '(regions)' ( which is included ),
		 * then it will break it. So we have to remove it.
		 */
		options.componentRestrictions = _.omit( options.componentRestrictions, 'postalCode' );
	}

	// Check if we need to restrict the autocomplete data.
    if ( typeof wpslSettings.autoCompleteOptions !== "undefined" && !$.isEmptyObject( wpslSettings.autoCompleteOptions ) ) {
        for ( var key in wpslSettings.autoCompleteOptions ) {
            if ( wpslSettings.autoCompleteOptions.hasOwnProperty( key ) ) {
                options[key] = wpslSettings.autoCompleteOptions[key];
            }
        }
    }

	input		  = document.getElementById( "wpsl-search-input" );
	autocomplete = new google.maps.places.Autocomplete( input, options );

	autocomplete.addListener( "place_changed", function() {
		place = autocomplete.getPlace();

		/**
		 * Assign the returned latlng to the autoCompleteLatLng var.
		 * This var is used when the users submits the search.
		 */
		if ( place.geometry ) {
            autoCompleteLatLng = place.geometry.location;
		}
    });
}

/**
 * Make sure that the 'Zoom here' link in the info window 
 * doesn't zoom past the max auto zoom level.
 * 
 * The 'max auto zoom level' is set on the settings page.
 *
 * @since   2.0.0
 * @returns {void}
 */
function zoomChangedListener() {
	if ( typeof wpslSettings.markerZoomTo !== "undefined" && wpslSettings.markerZoomTo == 1 ) {
		google.maps.event.addListener( map, "zoom_changed", function() {
			checkMaxZoomLevel();
		});
	}
}

/**
 * Get the correct map settings.
 *
 * @since	2.0.0
 * @param	{number} mapIndex    Number of the map
 * @returns {object} mapSettings The map settings either set through a shortcode or the default settings 
 */
function getMapSettings( mapIndex ) {
	var j, len, shortCodeVal,
		settingOptions = [ "zoomLevel", "mapType", "mapTypeControl", "mapStyle", "streetView", "scrollWheel", "controlPosition" ], 
		mapSettings	= {
			zoomLevel: wpslSettings.zoomLevel,
			mapType: wpslSettings.mapType,
			mapTypeControl: wpslSettings.mapTypeControl,
			mapStyle: wpslSettings.mapStyle,
			streetView: wpslSettings.streetView,
			scrollWheel: wpslSettings.scrollWheel,
			controlPosition: wpslSettings.controlPosition,
            gestureHandling: wpslSettings.gestureHandling
		};	

	// If there are settings that are set through the shortcode, then we use them instead of the default ones.
	if ( ( typeof window[ "wpslMap_" + mapIndex ] !== "undefined" ) && ( typeof window[ "wpslMap_" + mapIndex ].shortCode !== "undefined" ) ) {
		for ( j = 0, len = settingOptions.length; j < len; j++ ) {
			shortCodeVal = window[ "wpslMap_" + mapIndex ].shortCode[ settingOptions[j] ];
			
			// If the value is set through the shortcode, we overwrite the default value.
			if ( typeof shortCodeVal !== "undefined" ) {
				mapSettings[ settingOptions[j] ] = shortCodeVal;
			}
		}
	}

	mapSettings.startLatLng = getStartLatlng( mapIndex );

	return mapSettings;
}

/**
 * Get the latlng coordinates that are used to init the map.
 *
 * @since	2.0.0
 * @param	{number} mapIndex    Number of the map
 * @returns {object} startLatLng The latlng value where the map will initially focus on 
 */
function getStartLatlng( mapIndex ) {
	var startLatLng, latLng, 
		firstLocation = "";
	
	/* 
	 * Maps that are added with the [wpsl_map] shortcode will have the locations key set. 
	 * If it exists we use the coordinates from the first location to center the map on. 
	 */
	if ( ( typeof window[ "wpslMap_" + mapIndex ] !== "undefined" ) && ( typeof window[ "wpslMap_" + mapIndex ].locations !== "undefined" ) ) {
		firstLocation = window[ "wpslMap_" + mapIndex ].locations[0];
	}
		
	/* 
	 * Either use the coordinates from the first location as the start coordinates 
	 * or the default start point defined on the settings page.
	 * 
	 * If both are not available we set it to 0,0 
	 */	
	if ( ( typeof firstLocation !== "undefined" && typeof firstLocation.lat !== "undefined" ) && ( typeof firstLocation.lng !== "undefined" ) ) {
		startLatLng = new google.maps.LatLng( firstLocation.lat, firstLocation.lng );
	} else if ( wpslSettings.startLatlng !== "" ) {
		latLng		= wpslSettings.startLatlng.split( "," );
		startLatLng = new google.maps.LatLng( latLng[0], latLng[1] );
    } else {
		startLatLng = new google.maps.LatLng( 0,0 );
    }
		
	return startLatLng;
}

/**
 * Create a new infoWindow object.
 * 
 * Either use the default infoWindow or use the infobox library.
 * 
 * @since  2.0.0
 * @return {object} infoWindow The infoWindow object
 */
function newInfoWindow() {
	var boxClearance, boxPixelOffset, 
		infoBoxOptions = {};
	
	// Do we need to use the infobox script or use the default info windows?
	if ( ( typeof wpslSettings.infoWindowStyle !== "undefined" ) && ( wpslSettings.infoWindowStyle == "infobox" ) ) {

		// See http://google-maps-utility-library-v3.googlecode.com/svn/trunk/infobox/docs/reference.html.
		boxClearance   = wpslSettings.infoBoxClearance.split( "," );
		boxPixelOffset = wpslSettings.infoBoxPixelOffset.split( "," );
		infoBoxOptions = {
			alignBottom: true,
			boxClass: wpslSettings.infoBoxClass,
			closeBoxMargin: wpslSettings.infoBoxCloseMargin,
			closeBoxURL: wpslSettings.infoBoxCloseUrl,
			content: "",
			disableAutoPan: ( Number( wpslSettings.infoBoxDisableAutoPan ) ) ? true : false,
			enableEventPropagation: ( Number( wpslSettings.infoBoxEnableEventPropagation ) ) ? true : false,
			infoBoxClearance: new google.maps.Size( Number( boxClearance[0] ), Number( boxClearance[1] ) ),
			pixelOffset: new google.maps.Size( Number( boxPixelOffset[0] ), Number( boxPixelOffset[1] ) ),
			zIndex: Number( wpslSettings.infoBoxZindex )
		};

		infoWindow = new InfoBox( infoBoxOptions );	
	} else {
		infoWindow = new google.maps.InfoWindow();
	}

	return infoWindow;
}

/**
 * Get the required marker settings.
 * 
 * @since  2.1.0
 * @return {object} settings The marker settings.
 */
function getMarkerSettings() {
	var markerProp,
		markerProps = wpslSettings.markerIconProps,
		settings	= {};

	// Use the correct marker path.
	if ( typeof markerProps.url !== "undefined" ) {
        settings.url = markerProps.url;
    } else if ( typeof markerProps.categoryMarkerUrl !== "undefined" ) {
        settings.categoryMarkerUrl = markerProps.categoryMarkerUrl;
    } else if ( typeof markerProps.alternateMarkerUrl !== "undefined" ) {
        settings.alternateMarkerUrl = markerProps.alternateMarkerUrl;
	} else {
		settings.url = wpslSettings.url + "img/markers/";
	}

	for ( var key in markerProps ) {
		if ( markerProps.hasOwnProperty( key ) ) {
			markerProp = markerProps[key].split( "," );

			if ( markerProp.length == 2 ) {
				settings[key] = markerProp;
			}
		}
	}
	
	return settings;
}

/**
 * Check if we have a map style that we need to apply to the map.
 * 
 * @since  2.0.0
 * @param  {string} mapStyle The id of the map
 * @return {void}
 */
function maybeApplyMapStyle( mapStyle ) {
	
	// Make sure the JSON is valid before applying it as a map style.
	mapStyle = tryParseJSON( mapStyle );

	if ( mapStyle ) {
		map.setOptions({ styles: mapStyle });
	}
}

/**
 * Make sure the JSON is valid. 
 * 
 * @link   http://stackoverflow.com/a/20392392/1065294 
 * @since  2.0.0
 * @param  {string} jsonString The JSON data
 * @return {object|boolean}	The JSON string or false if it's invalid json.
 */
function tryParseJSON( jsonString ) {
	
    try {
        var o = JSON.parse( jsonString );

        /* 
		 * Handle non-exception-throwing cases:
		 * Neither JSON.parse(false) or JSON.parse(1234) throw errors, hence the type-checking,
		 * but... JSON.parse(null) returns 'null', and typeof null === "object", 
		 * so we must check for that, too.
		 */ 
        if ( o && typeof o === "object" && o !== null ) {
            return o;
        }
    }
    catch ( e ) { }

    return false;
}

/**
 * Add the start marker and call the function that inits the store search.
 *
 * @since	1.1.0
 * @param	{object} startLatLng The start coordinates
 * @param	{object} infoWindow  The infoWindow object
 * @returns {void}
 */
function showStores( startLatLng, infoWindow ) {
	addMarker( startLatLng, 0, '', true, infoWindow ); // This marker is the 'start location' marker. With a storeId of 0, no name and is draggable
	findStoreLocations( startLatLng, resetMap, autoLoad, infoWindow );
}

/**
 * Compare the current useragent to a list of known mobile useragents ( not optimal, I know ).
 *
 * @since	1.2.20
 * @returns {boolean} Whether the useragent is from a known mobile useragent or not.
 */
function checkMobileUserAgent() {
	return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test( navigator.userAgent );	
}

/**
 * Check if Geolocation detection is supported. 
 * 
 * If there is an error / timeout with determining the users 
 * location, then we use the 'start point' value from the settings 
 * as the start location through the showStores function. 
 *
 * @since	1.0.0
 * @param	{object} startLatLng The start coordinates
 * @param	{object} infoWindow  The infoWindow object
 * @returns {void}
 */
function checkGeolocation( startLatLng, infoWindow ) {
		
	if ( navigator.geolocation ) {
		var geolocationInProgress, locationTimeout,
			keepStartMarker = false,
			timeout			= Number( wpslSettings.geoLocationTimeout );
	
		// Make the direction icon flash every 600ms to indicate the geolocation attempt is in progress.
		geolocationInProgress = setInterval( function() {
			$( ".wpsl-icon-direction" ).toggleClass( "wpsl-active-icon" );
		}, 600 );

		/* 
		 * If the user doesn't approve the geolocation request within the value set in 
		 * wpslSettings.geoLocationTimeout, then the default map is loaded.
		 * 
		 * You can increase the timeout value with the wpsl_geolocation_timeout filter. 
		 */
		locationTimeout = setTimeout( function() {
			geolocationFinished( geolocationInProgress );
			showStores( startLatLng, infoWindow );
		}, timeout );

		navigator.geolocation.getCurrentPosition( function( position ) {
			geolocationFinished( geolocationInProgress );
			clearTimeout( locationTimeout );
			
			/* 
			 * If the timeout is triggerd and the user later decides to enable 
			 * the geolocation detection again, it gets messy with multiple start markers. 
			 * 
			 * So we first clear the map before adding new ones.
			 */
			deleteOverlays( keepStartMarker ); 
			handleGeolocationQuery( startLatLng, position, resetMap, infoWindow );
			
			/*
			 * Workaround for this bug in Firefox https://bugzilla.mozilla.org/show_bug.cgi?id=1283563.
			 * to keep track if the geolocation code has already run.
			 * 
			 * Otherwise after the users location is determined succesfully the code 
			 * will also detect the returned error, and triggers showStores() to 
			 * run with the start location set in the incorrect location.
			 */ 
			
			$( ".wpsl-search").addClass( "wpsl-geolocation-run" );
		}, function( error ) {

			/* 
			 * Only show the geocode errors if the user actually clicked on the direction icon. 
			 * 
			 * Otherwise if the "Attempt to auto-locate the user" option is enabled on the settings page, 
			 * and the geolocation attempt fails for whatever reason ( blocked in browser, unavailable etc ). 
			 * Then the first thing the visitor will see on pageload is an alert box, which isn't very userfriendly.
			 * 
			 * If an error occurs on pageload without the user clicking on the direction icon,
			 * the default map is shown without any alert boxes.
			 */
			if ( $( ".wpsl-icon-direction" ).hasClass( "wpsl-user-activated" ) && !$( ".wpsl-search" ).hasClass( "wpsl-geolocation-run" ) ) {
				switch ( error.code ) {
					case error.PERMISSION_DENIED:
						alert( wpslGeolocationErrors.denied );
						break;
					case error.POSITION_UNAVAILABLE:
						alert( wpslGeolocationErrors.unavailable );
						break;
					case error.TIMEOUT:
						alert( wpslGeolocationErrors.timeout );
						break;
					default:
						alert( wpslGeolocationErrors.generalError );
						break;
				}

				$( ".wpsl-icon-direction" ).removeClass( "wpsl-active-icon" );
			} else if ( !$( ".wpsl-search" ).hasClass( "wpsl-geolocation-run" ) ) {
				clearTimeout( locationTimeout );
				showStores( startLatLng, infoWindow );
			}
		},
		{ maximumAge: 60000, timeout: timeout, enableHighAccuracy: true } );
	} else {
		alert( wpslGeolocationErrors.unavailable );
		showStores( startLatLng, infoWindow );
	}
}

/**
 * Clean up after the geolocation attempt finished.
 * 
 * @since	2.0.0
 * @param	{number} geolocationInProgress
 * @returns {void}
 */
function geolocationFinished( geolocationInProgress ) {
	clearInterval( geolocationInProgress );
	$( ".wpsl-icon-direction" ).removeClass( "wpsl-active-icon" );	
}

/**
 * Handle the data returned from the Geolocation API.
 * 
 * If there is an error / timeout determining the users location,
 * then we use the 'start point' value from the settings as the start location through the showStores function. 
 *
 * @since	1.0.0
 * @param	{object}  startLatLng The start coordinates
 * @param	{object}  position    The latlng coordinates from the geolocation attempt
 * @param	{boolean} resetMap    Whether we should reset the map or not
 * @param	{object}  infoWindow  The infoWindow object
 * @returns {void}
 */
function handleGeolocationQuery( startLatLng, position, resetMap, infoWindow ) {

	if ( typeof( position ) === "undefined" ) {
		showStores( startLatLng, infoWindow );
	} else {
		var latLng = new google.maps.LatLng( position.coords.latitude, position.coords.longitude );
		
		/* 
		 * Store the latlng from the geolocation for when the user hits "reset" again 
		 * without having to ask for permission again.
		 */
        userGeolocation = {
            position: position,
			newRequest: true
		};

		map.setCenter( latLng );
		addMarker( latLng, 0, '', true, infoWindow ); // This marker is the 'start location' marker. With a storeId of 0, no name and is draggable
		findStoreLocations( latLng, resetMap, autoLoad, infoWindow );
	}
}

/**
 * Handle clicks on the store locator search button.
 * 
 * @since	1.0.0
 * @todo disable button while AJAX request still runs.
 * @param	{object} infoWindow The infoWindow object
 * @returns {void}
 */
function searchLocationBtn( infoWindow ) {

	$( "#wpsl-search-btn" ).unbind( "click" ).bind( "click", function( e ) {
		$( "#wpsl-search-input" ).removeClass();

		if ( !$( "#wpsl-search-input" ).val() ) {
			$( "#wpsl-search-input" ).addClass( "wpsl-error" ).focus();
		} else {
			resetSearchResults();

			/*
             * Check if we need to geocode the user input,
             * or if autocomplete is enabled and we already
             * have the latlng values.
             */
			if ( wpslSettings.autoComplete == 1 && typeof autoCompleteLatLng !== "undefined" ) {
				prepareStoreSearch( autoCompleteLatLng, infoWindow );
			} else {
				codeAddress( infoWindow );
			}
		}

		return false;
	});
}

/**
 * Force the open InfoBox info window to close
 * 
 * This is required if the user makes a new search, 
 * or clicks on the "Directions" link.
 *
 * @since  2.0.0
 * @return {void}
 */
function closeInfoBoxWindow() {
	if ( ( typeof wpslSettings.infoWindowStyle !== "undefined" ) && ( wpslSettings.infoWindowStyle == "infobox" ) && typeof openInfoWindow[0] !== "undefined" ) {
		openInfoWindow[0].close();
	}	
}

/**
 * Add the 'reload' and 'find location' icon to the map.
 *
 * @since  2.0.0
 * @param  {object} settings   Map settings
 * @param  {object} map		   The map object
 * @param  {object} infoWindow The info window object
 * @return {void}
 */
function mapControlIcons( settings, map, infoWindow ) {

	// Once the map has finished loading include the map control button(s).
	google.maps.event.addListenerOnce( map, "tilesloaded", function() {

		// Add the html for the map controls to the map.
		$( ".gm-style" ).append( wpslSettings.mapControls );

		if ( $( ".wpsl-icon-reset, #wpsl-reset-map" ).length > 0 ) {

			// Bind the reset map button.
			resetMapBtn( settings.startLatLng, infoWindow );

			/* 
			 * Hide it to prevent users from clicking it before 
			 * the store location are placed on the map. 
			 */
			$( ".wpsl-icon-reset" ).hide();
		}

		// Bind the direction button to trigger a new geolocation request.
		$( ".wpsl-icon-direction" ).on( "click", function() {
			$( this ).addClass( "wpsl-user-activated" );
			checkGeolocation( settings.startLatLng, infoWindow );
		});
	});
}

/**
 * Handle clicks on the "Reset" button.
 * 
 * @since	1.0.0
 * @param	{object} startLatLng The start coordinates
 * @param	{object} infoWindow  The infoWindow object
 * @returns {void}
 */
function resetMapBtn( startLatLng, infoWindow ) {
	$( ".wpsl-icon-reset, #wpsl-reset-map" ).on( "click", function() {
		var keepStartMarker = false,
			resetMap	    = true;

		/* 
		 * Check if a map reset is already in progress, 
		 * if so prevent another one from starting. 
		 */
		if ( $( this ).hasClass( "wpsl-in-progress" ) ) {
			return;
		}

		/* 
		 * When the start marker is dragged the autoload value is set to false. 
		 * So we need to check the correct value when the reset button is 
		 * pushed before reloading the stores. 
		 */
		if ( wpslSettings.autoLoad == 1 ) {
			autoLoad = 1;
		}

		// Check if the latlng or zoom has changed since pageload, if so there is something to reset.
		if ( ( ( ( map.getCenter().lat() !== mapDefaults.centerLatlng.lat() ) || ( map.getCenter().lng() !== mapDefaults.centerLatlng.lng() ) || ( map.getZoom() !== mapDefaults.zoomLevel ) ) ) ) {
			deleteOverlays( keepStartMarker );

			$( "#wpsl-search-input" ).val( "" ).removeClass();

			// We use this to prevent multiple reset request.
			$( ".wpsl-icon-reset" ).addClass( "wpsl-in-progress" );

			// If marker clusters exist, remove them from the map.
			if ( markerClusterer ) {
				markerClusterer.clearMarkers();
			}

			// Remove the start marker.
			deleteStartMarker();

			// Reset the dropdown values.
			resetDropdowns();

			if ( wpslSettings.autoLocate == 1 ) {
				handleGeolocationQuery( startLatLng, userGeolocation.position, resetMap, infoWindow );
			} else {
				showStores( startLatLng, infoWindow );
			}
		}

		// Make sure the stores are shown and the direction details are hidden.
		$( "#wpsl-stores" ).show();
		$( "#wpsl-direction-details" ).hide();
	});
}

/**
 * Remove the start marker from the map.
 *
 * @since   1.2.12
 * @returns {void}
 */
function deleteStartMarker() {
	if ( ( typeof( startMarkerData ) !== "undefined" ) && ( startMarkerData !== "" ) ) {
		startMarkerData.setMap( null );
		startMarkerData = "";
	}
}

/**
 * Reset the dropdown values for the max results, 
 * and search radius after the "reset" button is triggerd.
 * 
 * @since   1.1.0
 * @returns {void}
 */
function resetDropdowns() {
	var i, arrayLength, dataValue, catText, $customDiv, $customFirstLi, customSelectedText, customSelectedData,
		defaultFilters = $( "#wpsl-wrap" ).hasClass( "wpsl-default-filters" ),
		defaultValues  = [wpslSettings.searchRadius + ' ' + wpslSettings.distanceUnit, wpslSettings.maxResults],
		dropdowns	   = ["wpsl-radius", "wpsl-results"];

	for ( i = 0, arrayLength = dropdowns.length; i < arrayLength; i++ ) {
		$( "#" + dropdowns[i] + " select" ).val( parseInt( defaultValues[i] ) );
		$( "#" + dropdowns[i] + " li" ).removeClass();

		if ( dropdowns[i] == "wpsl-radius" ) {
			dataValue = wpslSettings.searchRadius;
		} else if ( dropdowns[i] == "wpsl-results" ) {
			dataValue = wpslSettings.maxResults;
		}

		$( "#" + dropdowns[i] + " li" ).each( function() {
			if ( $( this ).text() === defaultValues[i] ) {
				$( this ).addClass( "wpsl-selected-dropdown" );

				$( "#" + dropdowns[i] + " .wpsl-selected-item" ).html( defaultValues[i] ).attr( "data-value", dataValue );
			}
		});
	}

	/** 
	 * Reset the category dropdown.
	 * @todo look for other way to do this in combination with above code. Maybe allow users to define a default cat on the settings page?
	 */
	if ( $( "#wpsl-category" ).length ) {
		$( "#wpsl-category select" ).val( 0 );
		$( "#wpsl-category li" ).removeClass();
		$( "#wpsl-category li:first-child" ).addClass( "wpsl-selected-dropdown" );

		catText = $( "#wpsl-category li:first-child" ).text();

		$( "#wpsl-category .wpsl-selected-item" ).html( catText ).attr( "data-value", 0 );
	}

	// If any custom dropdowns exist, then we reset them as well.
	if ( $( ".wpsl-custom-dropdown" ).length > 0 ) {
		$( ".wpsl-custom-dropdown" ).each( function( index ) {
			
			// Check if we are dealing with the styled dropdowns, or the default select dropdowns.
			if ( !defaultFilters ) {
				$customDiv		   = $( this ).siblings( "div" );
				$customFirstLi	   = $customDiv.find( "li:first-child" );
				customSelectedText = $customFirstLi.text();
				customSelectedData = $customFirstLi.attr( "data-value" );

				$customDiv.find( "li" ).removeClass();
				$customDiv.prev().html( customSelectedText ).attr( "data-value", customSelectedData );	
			} else {
				$( this ).find( "option" ).removeAttr( "selected" );
			}
		});
	}
}

// Handle the click on the back button when the route directions are displayed.
$( "#wpsl-result-list" ).on( "click", ".wpsl-back", function() {	
	var i, len;

    // Remove the directions from the map.
    directionsDisplay.setMap( null );

    // Restore the store markers on the map.
    for ( i = 0, len = markersArray.length; i < len; i++ ) {
		markersArray[i].setMap( map );
    }

	// Restore the start marker on the map.
	if ( ( typeof( startMarkerData ) !== "undefined" )  && ( startMarkerData !== "" ) ) {
		startMarkerData.setMap( map );
	}

	// If marker clusters are enabled, restore them.
	if ( markerClusterer ) {
		checkMarkerClusters();			
	}

	map.setCenter( directionMarkerPosition.centerLatlng );
	map.setZoom( directionMarkerPosition.zoomLevel );	

    $( ".wpsl-direction-before, .wpsl-direction-after" ).remove();
    $( "#wpsl-stores" ).show();
    $( "#wpsl-direction-details" ).hide();

    return false;
});

/**
 * Show the driving directions.
 * 
 * @since	1.1.0
 * @param	{object} e The clicked elemennt
 * @returns {void}
 */
function renderDirections( e ) {
    var i, start, end, len, storeId;
	
	// Force the open InfoBox info window to close.
	closeInfoBoxWindow();

    /* 
     * The storeId is placed on the li in the results list, 
     * but in the marker it will be on the wrapper div. So we check which one we need to target.
     */
    if ( e.parents( "li" ).length > 0 ) {
		storeId = e.parents( "li" ).data( "store-id" );
    } else {
		storeId = e.parents( ".wpsl-info-window" ).data( "store-id" );
    }
	
	// Check if we need to get the start point from a dragged marker.
	if ( ( typeof( startMarkerData ) !== "undefined" )  && ( startMarkerData !== "" ) ) {
		start = startMarkerData.getPosition();
	}
	
	// Used to restore the map back to the state it was in before the user clicked on 'directions'.
	directionMarkerPosition = {
		centerLatlng: map.getCenter(),
		zoomLevel: map.getZoom()	
	};

    // Find the latlng that belongs to the start and end point.
    for ( i = 0, len = markersArray.length; i < len; i++ ) {
		
		// Only continue if the start data is still empty or undefined.
		if ( ( markersArray[i].storeId == 0 ) && ( ( typeof( start ) === "undefined" ) || ( start === "" ) ) ) {
			start = markersArray[i].getPosition();
		} else if ( markersArray[i].storeId == storeId ) {
			end = markersArray[i].getPosition();
		}
    }
	
    if ( start && end ) {
		$( "#wpsl-direction-details ul" ).empty();
		$( ".wpsl-direction-before, .wpsl-direction-after" ).remove();
		calcRoute( start, end );
    } else {
		alert( wpslLabels.generalError );
    } 
}

/**
 * Check what effect is triggerd once a user hovers over the store list. 
 * Either bounce the corresponding marker up and down, open the info window or ignore it.
 */
if ( $( "#wpsl-gmap" ).length ) {	
	if ( wpslSettings.markerEffect == 'bounce' ) {
		$( "#wpsl-stores" ).on( "mouseenter", "li", function() {
			letsBounce( $( this ).data( "store-id" ), "start" );
		});

		$( "#wpsl-stores" ).on( "mouseleave", "li", function() {	
			letsBounce( $( this ).data( "store-id" ), "stop" );
		});
	} else if ( wpslSettings.markerEffect == 'info_window' ) {
		$( "#wpsl-stores" ).on( "mouseenter", "li", function() {
			var i, len;

			for ( i = 0, len = markersArray.length; i < len; i++ ) {
				if ( markersArray[i].storeId == $( this ).data( "store-id" ) ) {
					google.maps.event.trigger( markersArray[i], "click" );
					map.setCenter( markersArray[i].position );
				}
			}
		});	
	}
}

/**
 * Let a single marker bounce.
 * 
 * @since	1.0.0
 * @param	{number} storeId The storeId of the marker that we need to bounce on the map
 * @param	{string} status  Indicates whether we should stop or start the bouncing
 * @returns {void}
 */
function letsBounce( storeId, status ) {
    var i, len, marker;

    // Find the correct marker to bounce based on the storeId.
    for ( i = 0, len = markersArray.length; i < len; i++ ) {
		if ( markersArray[i].storeId == storeId ) {
			marker = markersArray[i];
			
			if ( status == "start" ) {
				marker.setAnimation( google.maps.Animation.BOUNCE );	
			} else {
				marker.setAnimation( null );	
			}
		}
    }	
}

/**
 * Calculate the route from the start to the end.
 * 
 * @since	1.0.0
 * @param	{object} start The latlng from the start point
 * @param	{object} end   The latlng from the end point
 * @returns {void}
 */
function calcRoute( start, end ) {
    var legs, len, step, index, direction, i, j,
		distanceUnit, directionOffset, request,
		directionStops = "";
		
	if ( wpslSettings.distanceUnit == "km" ) {
		distanceUnit = 'METRIC';
	} else {
		distanceUnit = 'IMPERIAL';
	}

	request = {
		origin: start,
		destination: end,
		travelMode: wpslSettings.directionsTravelMode,
		unitSystem: google.maps.UnitSystem[ distanceUnit ] 
	};

    directionsService.route( request, function( response, status ) {
		if ( status == google.maps.DirectionsStatus.OK ) {
			directionsDisplay.setMap( map );
			directionsDisplay.setDirections( response );

			if ( response.routes.length > 0 ) {
				direction = response.routes[0];

				// Loop over the legs and steps of the directions.
				for ( i = 0; i < direction.legs.length; i++ ) {
					legs = direction.legs[i];

					for ( j = 0, len = legs.steps.length; j < len; j++ ) {
						step = legs.steps[j];
						index = j+1;
						directionStops = directionStops + "<li><div class='wpsl-direction-index'>" + index + "</div><div class='wpsl-direction-txt'>" + step.instructions + "</div><div class='wpsl-direction-distance'>" + step.distance.text + "</div></li>";
					}
				}

				$( "#wpsl-direction-details ul" ).append( directionStops ).before( "<div class='wpsl-direction-before'><a class='wpsl-back' id='wpsl-direction-start' href='#'>" + wpslLabels.back + "</a><div><span class='wpsl-total-distance'>" + direction.legs[0].distance.text + "</span> - <span class='wpsl-total-durations'>" + direction.legs[0].duration.text + "</span></div></div>" ).after( "<p class='wpsl-direction-after'>" + response.routes[0].copyrights + "</p>" );
				$( "#wpsl-direction-details" ).show();
				
				// Remove all single markers from the map.
				for ( i = 0, len = markersArray.length; i < len; i++ ) {
					markersArray[i].setMap( null );
				}
			
				// Remove the marker clusters from the map.
				if ( markerClusterer ) {
					markerClusterer.clearMarkers();
				}			
				
				// Remove the start marker from the map.
				if ( ( typeof( startMarkerData ) !== "undefined" ) && ( startMarkerData !== "" ) ) {
					startMarkerData.setMap( null );
				}

				$( "#wpsl-stores" ).hide();		
								
				// Make sure the start of the route directions are visible if the store listings are shown below the map.				
				if ( wpslSettings.templateId == 1 ) {
					directionOffset = $( "#wpsl-gmap" ).offset();
					$( window ).scrollTop( directionOffset.top );
				}
			}
		} else {
			directionErrors( status );
		}
    });
}

/**
 * Geocode the user input.
 * 
 * @since	1.0.0
 * @param	{object} infoWindow The infoWindow object
 * @returns {void}
 */
function codeAddress( infoWindow ) {
    var latLng, request = {};

    // Check if we need to set the geocode component restrictions.
	if ( typeof wpslSettings.geocodeComponents !== "undefined" && !$.isEmptyObject( wpslSettings.geocodeComponents ) ) {
		request.componentRestrictions = wpslSettings.geocodeComponents;

		if ( typeof request.componentRestrictions.postalCode !== "undefined" ) {
            request.componentRestrictions.postalCode = $( "#wpsl-search-input" ).val();
        } else {
            request.address = $( "#wpsl-search-input" ).val();
		}
	} else {
        request.address = $( "#wpsl-search-input" ).val();
	}

    geocoder.geocode( request, function( response, status ) {
		if ( status == google.maps.GeocoderStatus.OK ) {

			if ( statistics.enabled ) {
				collectStatsData( response );
			}

			latLng = response[0].geometry.location;

            prepareStoreSearch( latLng, infoWindow );
		} else {
			geocodeErrors( status );
		}
    });
}

/**
 * Prepare a new location search.
 * 
 * @since	2.2.0
 * @param	{object} latLng 	The coordinates
 * @param	{object} infoWindow The infoWindow object.
 * @returns {void}
 */
function prepareStoreSearch( latLng, infoWindow ) {
	var autoLoad = false;

	// Add a new start marker.
	addMarker( latLng, 0, '', true, infoWindow );

	// Try to find stores that match the radius, location criteria.
	findStoreLocations( latLng, resetMap, autoLoad, infoWindow );
}

/**
 * Reverse geocode the passed coordinates and set the returned zipcode in the input field.
 *
 * @since	1.0.0
 * @param	{object} latLng The coordinates of the location that should be reverse geocoded
 * @returns {object} response The address components if the stats add-on is active.
 */
function reverseGeocode( latLng, callback ) {
    var userLocation,
		lat = latLng.lat().toFixed( 5 ),
		lng = latLng.lng().toFixed( 5 );

    latLng.lat = function() {
        return parseFloat( lat );
    };

    latLng.lng = function() {
        return parseFloat( lng );
    };

    geocoder.geocode( {'latLng': latLng }, function( response, status ) {
        if ( status == google.maps.GeocoderStatus.OK ) {

			if ( wpslSettings.autoLocate == 1 && userGeolocation.newRequest ) {
                userLocation = filterApiResponse( response );

				if ( userLocation !== "" ) {
					$( "#wpsl-search-input" ).val( userLocation );
				}

                /*
                 * Prevent the zip from being placed in the input field
                 * again after the users location is determined.
                 */
                userGeolocation.newRequest = false;
			}

            if ( wpslSettings.directionRedirect ) {
                startAddress = response[0].formatted_address;
            }

            // Prevent it from running on autoload when the input field is empty.
            if ( statistics.enabled && $( "#wpsl-search-input" ).val().length > 0 ) {
                if ( $.isEmptyObject( statistics.addressComponents ) ) {
                    collectStatsData( response );
                }
            }

            callback();
		} else {
			geocodeErrors( status );
		}
	});
}

/**
 * Collect the data for the statistics
 * add-on from the Google Geocode API.
 *
 * @since 2.2.18
 * @param response
 * @returns {void}
 */
function collectStatsData( response ) {
	var requiredFields, addressLength, responseType,
        countryCode, responseLength,
        missingFields = {},
        statsData 	   = {};

    countryCode = findCountryCode( response );

    /**
     * The UK is a special case how the city / town / region / country data
     * is structured in the Geocode API response. So we adjust the structure a bit.
     *
     * We later check which field contained the city / town data
     * and if necessary later move it to the correct one.
     */
    if ( countryCode == "GB" ) {
        requiredFields = {
            'city': 'postal_town',
            'city_locality': 'locality,political',
            'region': 'administrative_area_level_2,political',
            'country': 'administrative_area_level_1,political'
        };
    } else {
        requiredFields = {
            'city': 'locality,political',
            'region': 'administrative_area_level_1,political',
            'country': 'country,political'
        };
    }

    addressLength = response[0].address_components.length;

    // Loop over the first row in the API response.
    for ( i = 0; i < addressLength; i++ ) {
        responseType = response[0].address_components[i].types;

        for ( var key in requiredFields ) {
            if ( requiredFields[key] == responseType.join( "," ) ) {

                // In rare cases the long name is empty.
                if ( response[0].address_components[i].long_name.length > 0 ) {
                    statsData[key] = response[0].address_components[i].long_name;
                } else {
                    statsData[key] = response[0].address_components[i].short_name;
                }
            }
        }
    }

    /**
     * Check if we have the required fields. This is often the case after
     * grabbing the data from the first row, but in some cases we have to loop
     * through all the data to get all the required data.
     */
    for ( var key in requiredFields ) {
        if ( typeof statsData[key] === "undefined" ) {
            missingFields[key] = requiredFields[key];
        }
    }

    /**
     * In the UK the data we want is most of the time in the
     * postal_town ( city ) field, which is often set on the first row.
     *
     * If this field contains data then don't continue and ignore
     * the missing data in the locality field, which is more of a
     * backup in case the 'postal_town' is missing in the API response.
     */
    if ( countryCode == "GB" ) {
        if ( typeof missingFields.city_locality !== "undefined" && typeof missingFields.city === "undefined" ) {
            missingFields = {};
        }
    }

    /**
     * If one or more required fields are missing,
     * then loop through the remaining API data.
     */
    if ( Object.keys( missingFields ).length > 0 ) {
        responseLength = response.length;

        /**
         * Loop over the remaining API results,
         * but skip the first row since we already checked that one.
         */
        for ( i = 1; i < responseLength; i++ ) {
            addressLength = response[i].address_components.length;

            for ( j = 0; j < addressLength; j++ ) {
                responseType = response[i].address_components[j].types;

                for ( var key in missingFields ) {
                    if ( requiredFields[key] == responseType.join( "," ) ) {
                        statsData[key] = response[i].address_components[j].long_name;
                    }
                }
            }
        }
    }

    /**
     * In rare cases, and as far I know this only happens in the UK, the city / town name
     * is often set in the 'postal_town' ( city ) field in the Google API response.
     *
     * But in some cases the 'locality,political' ( city_locality ) field is also
     * set in the first row ( where it's located for locations in the rest of the world ).
     *
     * When both fields are set the 'locality,political' ( city_locality ) will contain more
     * accurate details, so we copy it's value back to the city field.
     */
    if ( typeof statsData.city_locality !== "undefined" && statsData.city_locality.length > 0 ) {
        statsData.city = statsData.city_locality;

        delete statsData.city_locality;
    }

    statistics.addressComponents = statsData;
}

/**
 * Grab the country name from the API response.
 *
 * @since 2.2.18
 * @param {object}  response 	 The API response
 * @return {string} countryCode The country code found in the API response.
 */
function findCountryCode( response ) {
	var responseType, countryCode = '';

    $.each( response[0].address_components, function( index ) {
        responseType = response[0].address_components[index].types;

        if ( responseType.join( ',' ) == 'country,political' ) {
            countryCode = response[0].address_components[index].short_name;

            return false;
        }
    });

	return countryCode;
}

/**
 * Filter out the zip / city name from the API response
 *
 * @since	1.0.0
 * @param	{object} response 	   The complete Google API response
 * @returns {string} userLocation Either the users zip / city name the user is located in
 */
function filterApiResponse( response ) {
    var i, j, responseType, addressLength, userLocation, filteredData = {},
		responseLength = response.length;

	for ( i = 0; i < responseLength; i++ ) {
		addressLength = response[i].address_components.length;

		for ( j = 0; j < addressLength; j++ ) {
			responseType = response[i].address_components[j].types;

			if ( ( /^postal_code$/.test( responseType ) ) || ( /^postal_code,postal_code_prefix$/.test( responseType ) ) ) {
				filteredData.zip = response[i].address_components[j].long_name;

				break;
			}

			if ( /^locality,political$/.test( responseType ) ) {
				filteredData.locality = response[i].address_components[j].long_name;
			}
		}

		if ( typeof filteredData.zip !== "undefined" ) {
			break;
		}
	}

	// If no zip code was found ( it's rare, but it happens ), then we use the city / town name as backup.
	if ( typeof filteredData.zip === "undefined" && typeof filteredData.locality !== "undefined" ) {
		userLocation = filteredData.locality;
	} else {
		userLocation = filteredData.zip;
	}

    return userLocation;
}

/**
 * Call the function to make the ajax request to load the store locations. 
 * 
 * If we need to show the driving directions on maps.google.com itself, 
 * we first need to geocode the start latlng into a formatted address.
 * 
 * @since	1.0.0
 * @param	{object}  startLatLng The coordinates
 * @param	{boolean} resetMap    Whether we should reset the map or not
 * @param	{string}  autoLoad    Check if we need to autoload all the stores
 * @param	{object}  infoWindow  The infoWindow object
 * @returns {void}
 */
function findStoreLocations( startLatLng, resetMap, autoLoad, infoWindow ) {

	if ( wpslSettings.directionRedirect == 1 || statistics.enabled ) {
        reverseGeocode( startLatLng, function() {
			makeAjaxRequest( startLatLng, resetMap, autoLoad, infoWindow );
		});
	} else {
		makeAjaxRequest( startLatLng, resetMap, autoLoad, infoWindow );
	}
}

/**
 * Make the AJAX request to load the store data.
 * 
 * @since	1.2.0
 * @param	{object}  startLatLng The latlng used as the starting point
 * @param	{boolean} resetMap    Whether we should reset the map or not
 * @param	{string}  autoLoad    Check if we need to autoload all the stores
 * @param	{object}  infoWindow  The infoWindow object
 * @returns {void}
 */
function makeAjaxRequest( startLatLng, resetMap, autoLoad, infoWindow ) {
	var latLng, noResultsMsg, ajaxData,
		storeData  = "",
		draggable  = false,
		template   = $( "#wpsl-listing-template" ).html(),
		$storeList = $( "#wpsl-stores ul" ),
		preloader  = wpslSettings.url + "img/ajax-loader.gif";

	ajaxData = collectAjaxData( startLatLng, resetMap, autoLoad );

    // Add the preloader.
	$storeList.empty().append( "<li class='wpsl-preloader'><img src='" + preloader + "'/>" + wpslLabels.preloader + "</li>" );

    $( "#wpsl-wrap" ).removeClass( "wpsl-no-results" );
		
	$.get( wpslSettings.ajaxurl, ajaxData, function( response ) {

	    // Remove the preloaders and no results msg.
        $( ".wpsl-preloader" ).remove();

		if ( response.length > 0 && typeof response.addon == "undefined" ) {

			// Loop over the returned locations.
			$.each( response, function( index ) {
				_.extend( response[index], templateHelpers ); 

				// Add the location maker to the map.
				latLng = new google.maps.LatLng( response[index].lat, response[index].lng );	
				addMarker( latLng, response[index].id, response[index], draggable, infoWindow );	

				// Create the HTML output with help from underscore js.
				storeData = storeData + _.template( template )( response[index] );
			});

			$( "#wpsl-result-list" ).off( "click", ".wpsl-directions" );

			// Remove the old search results.
			$storeList.empty();

			// Add the html for the store listing to the <ul>.
			$storeList.append( storeData );

			$( "#wpsl-result-list" ).on( "click", ".wpsl-directions", function() {

				// Check if we need to render the direction on the map.
				if ( wpslSettings.directionRedirect != 1 ) {
					renderDirections( $( this ) );

					return false;
				}
			});

			// Do we need to create a marker cluster?
			checkMarkerClusters();

			$( "#wpsl-result-list p:empty" ).remove();
		} else {
			addMarker( startLatLng, 0, '', true, infoWindow );
			
			noResultsMsg = getNoResultsMsg();

			$( "#wpsl-wrap" ).addClass( "wpsl-no-results" );
			
			$storeList.html( "<li class='wpsl-no-results-msg'>" + noResultsMsg + "</li>" );
		}
		
		/*
		 * Do we need to adjust the zoom level so that all the markers fit in the viewport,
		 * or just center the map on the start marker.
		 */
        if ( wpslSettings.runFitBounds == 1 ) {
            fitBounds();
		} else {
            map.setZoom( Number( wpslSettings.zoomLevel ) );
            map.setCenter( markersArray[0].position );
        }
		
		/*
		 * Store the default zoom and latlng values the first time 
		 * all the stores are added to the map.
		 * 
		 * This way when a user clicks the reset button we can check if the 
		 * zoom/latlng values have changed, and if they have, then we know we 
		 * need to reload the map.
		 */
		if ( wpslSettings.resetMap == 1 ) {
			if ( $.isEmptyObject( mapDefaults ) ) {
				google.maps.event.addListenerOnce( map, "tilesloaded", function() {
					mapDefaults = {
						centerLatlng: map.getCenter(),
						zoomLevel: map.getZoom()
					};
															
					/*
					 * Because the reset icon exists, we need to adjust 
					 * the styling of the direction icon. 
					 */
					$( "#wpsl-map-controls" ).addClass( "wpsl-reset-exists" );

					/*
					 * The reset initialy is set to hidden to prevent 
					 * users from clicking it before the map is loaded. 
					 */
					$( ".wpsl-icon-reset, #wpsl-reset-map" ).show();
				});
			}
			
			$( ".wpsl-icon-reset" ).removeClass( "wpsl-in-progress" );
		}
	});	
	
	// Move the mousecursor to the store search field if the focus option is enabled.
	if ( wpslSettings.mouseFocus == 1 && !checkMobileUserAgent() ) {
		$( "#wpsl-search-input" ).focus();
	}
}

/**
 * Collect the data we need to include in the AJAX request.
 * 
 * @since	2.2.0
 * @param	{object}  startLatLng The latlng used as the starting point
 * @param	{boolean} resetMap    Whether we should reset the map or not
 * @param	{string}  autoLoad    Check if we need to autoload all the stores
 * @returns {object}  ajaxData	  The collected data.
 */
function collectAjaxData( startLatLng, resetMap, autoLoad ) {
	var maxResult, radius, customDropdownName, customDropdownValue,
        customCheckboxName,
		categoryId	   = "",
		isMobile	   = $( "#wpsl-wrap" ).hasClass( "wpsl-mobile" ),
		defaultFilters = $( "#wpsl-wrap" ).hasClass( "wpsl-default-filters" ),
		ajaxData = {
			action: "store_search",
			lat: startLatLng.lat(),
			lng: startLatLng.lng()
		};
	
	/* 
	 * If we reset the map we use the default dropdown values instead of the selected values. 
	 * Otherwise we first make sure the filter val is valid before including the radius / max_results param
	 */
	if ( resetMap ) {
		ajaxData.max_results   = wpslSettings.maxResults;
		ajaxData.search_radius = wpslSettings.searchRadius;
	} else {
		if ( isMobile || defaultFilters ) {
			maxResult = parseInt( $( "#wpsl-results .wpsl-dropdown" ).val() );
			radius 	  = parseInt( $( "#wpsl-radius .wpsl-dropdown" ).val() );
		} else {
			maxResult = parseInt( $( "#wpsl-results .wpsl-selected-item" ).attr( "data-value" ) );
			radius    = parseInt( $( "#wpsl-radius .wpsl-selected-item" ).attr( "data-value" ) );
		}
		
		// If the max results or radius filter values are NaN, then we use the default value.
		if ( isNaN( maxResult ) ) {
			ajaxData.max_results = wpslSettings.maxResults;
		} else {
			ajaxData.max_results = maxResult;
		}
		
		if ( isNaN( radius ) ) {
			ajaxData.search_radius = wpslSettings.searchRadius;
		} else {
			ajaxData.search_radius = radius;
		}
		
		/* 
		 * If category ids are set through the wpsl shortcode, then we always need to include them.
		 * Otherwise check if the category dropdown exist, or if the checkboxes are used.
		 */
		if ( typeof wpslSettings.categoryIds !== "undefined" ) {
			ajaxData.filter = wpslSettings.categoryIds;
		} else if ( $( "#wpsl-category" ).length > 0 ) {
			if ( isMobile || defaultFilters ) {
				categoryId = parseInt( $( "#wpsl-category .wpsl-dropdown" ).val() );
			} else {
				categoryId = parseInt( $( "#wpsl-category .wpsl-selected-item" ).attr( "data-value" ) );				
			}

			if ( ( !isNaN( categoryId ) && ( categoryId !== 0 ) ) )  {
				ajaxData.filter = categoryId;
			}
		} else if ( $( "#wpsl-checkbox-filter" ).length > 0 ) {
			if ( $( "#wpsl-checkbox-filter input:checked" ).length > 0 ) {
				ajaxData.filter = getCheckboxIds();
			}
		}

		// Include values from custom dropdowns.
		if ( $( ".wpsl-custom-dropdown" ).length > 0 ) {
			$( ".wpsl-custom-dropdown" ).each( function( index ) {
				customDropdownName  = '';
				customDropdownValue = '';

				if ( isMobile || defaultFilters ) {
					customDropdownName  = $( this ).attr( "name" );
					customDropdownValue = $( this ).val();
				} else {
					customDropdownName  = $( this ).attr( "name" );
					customDropdownValue = $( this ).next( ".wpsl-selected-item" ).attr( "data-value" );
				}

				if ( customDropdownName && customDropdownValue ) {
					ajaxData[customDropdownName] = customDropdownValue;
				}
			});	
		}

		// Include values from custom checkboxes
        if ( $( ".wpsl-custom-checkboxes" ).length > 0 ) {
            $( ".wpsl-custom-checkboxes" ).each( function( index ) {
				customCheckboxName = $( this ).attr( "data-name" );

                if ( customCheckboxName ) {
                    ajaxData[customCheckboxName] = getCustomCheckboxValue( customCheckboxName );
                }
			});
        }
	}

   /*
	* If the autoload option is enabled, then we need to check if the included latlng 
	* is based on a geolocation attempt before including the autoload param.
	* 
	* Because if both the geolocation and autoload options are enabled, 
	* and the geolocation attempt was successful, then we need to to include
	* the skip_cache param. 
	* 
	* This makes sure the results don't come from an older transient based on the 
	* start location from the settings page, instead of the users actual location. 
	*/
    if ( autoLoad == 1 ) {
		if ( typeof userGeolocation.position !== "undefined" ) {
			ajaxData.skip_cache = 1;
		} else {
			ajaxData.autoload = 1;
			
			/* 
			 * If the user set the 'category' attr on the wpsl shortcode, then include the cat ids 
			 * to make sure only locations from the set categories are loaded on autoload.
			 */
			if ( typeof wpslSettings.categoryIds !== "undefined" ) {
				ajaxData.filter = wpslSettings.categoryIds;
			}
		}
	}
	
	// If the collection of statistics is enabled, then we include the searched value.
	if ( statistics.enabled && autoLoad == 0 ) {
		ajaxData.search = $( "#wpsl-search-input" ).val();
        ajaxData.statistics = statistics.addressComponents;
    }
	
	return ajaxData;
}

/**
 * Get custom checkbox values by data-name group.
 *
 * If multiple selection are made, then the returned
 * values are comma separated
 *
 * @since  2.2.8
 * @param  {string} customCheckboxName The data-name value of the custom checkbox
 * @return {string} customValue		   The collected checkbox values separated by a comma
 */
function getCustomCheckboxValue( customCheckboxName ) {
	var dataName    = $( "[data-name=" + customCheckboxName + "]" ),
		customValue = [];

	$( dataName ).find( "input:checked" ).each( function( index ) {
        customValue.push( $( this ).val() );
	});

	return customValue.join();
}

/**
 * Check which no results msg we need to show. 
 * 
 * Either the default txt or a longer custom msg.
 * 
 * @since  2.2.0
 * @return string noResults The no results msg to show.
 */
function getNoResultsMsg() {
	var noResults;
	
	if ( typeof wpslSettings.noResults !== "undefined" && wpslSettings.noResults !== "" ) {
		noResults = wpslSettings.noResults;
	} else {
		noResults = wpslLabels.noResults;
	}
	
	return noResults;
}

/**
 * Collect the ids of the checked checkboxes.
 * 
 * @since  2.2.0
 * @return string catIds The cat ids from the checkboxes.
 */
function getCheckboxIds() {
	var catIds = $( "#wpsl-checkbox-filter input:checked" ).map( function() {
		return $( this ).val();
	});
	
	catIds = catIds.get();
	catIds = catIds.join(',');
	
	return catIds;
}

/**
 * Check if cluster markers are enabled.
 * If so, init the marker clustering with the 
 * correct gridsize and max zoom.
 * 
 * @since  1.2.20
 * @return {void}
 */
function checkMarkerClusters() {
	if ( wpslSettings.markerClusters == 1 ) {
		var markers, markersArrayNoStart,
			clusterZoom = Number( wpslSettings.clusterZoom ),
			clusterSize = Number( wpslSettings.clusterSize );

		if ( isNaN( clusterZoom ) ) {
			clusterZoom = "";
		}
		
		if ( isNaN( clusterSize ) ) {
			clusterSize = "";
		}

        /*
         * Remove the start location marker from the cluster so the location
         * count represents the actual returned locations, and not +1 for the start location.
         */
		if ( typeof wpslSettings.excludeStartFromCluster !== "undefined" && wpslSettings.excludeStartFromCluster == 1 ) {
            markersArrayNoStart = markersArray.slice( 0 );
            markersArrayNoStart.splice( 0,1 );
        }

        markers = ( typeof markersArrayNoStart === "undefined" ) ? markersArray : markersArrayNoStart;

        markerClusterer = new MarkerClusterer( map, markers, {
			gridSize: clusterSize,
			maxZoom: clusterZoom
		});
	}
}

/**
 * Add a new marker to the map based on the provided location (latlng).
 * 
 * @since  1.0.0
 * @param  {object}  latLng		    The coordinates
 * @param  {number}  storeId		The store id
 * @param  {object}  infoWindowData The data we need to show in the info window
 * @param  {boolean} draggable      Should the marker be draggable
 * @param  {object}  infoWindow     The infoWindow object
 * @return {void}
 */
function addMarker( latLng, storeId, infoWindowData, draggable, infoWindow ) {
	var url, mapIcon, marker,
		keepStartMarker = true;

    if ( storeId === 0 ) {
        infoWindowData = {
            store: wpslLabels.startPoint
        };

        url = markerSettings.url + wpslSettings.startMarker;
    } else if ( typeof infoWindowData.alternateMarkerUrl !== "undefined" && infoWindowData.alternateMarkerUrl ) {
		url = infoWindowData.alternateMarkerUrl;
	} else if ( typeof infoWindowData.categoryMarkerUrl !== "undefined" && infoWindowData.categoryMarkerUrl ) {
		url = infoWindowData.categoryMarkerUrl;
	} else {
		url = markerSettings.url + wpslSettings.storeMarker;
	}

	mapIcon = {
		url: url,
		scaledSize: new google.maps.Size( Number( markerSettings.scaledSize[0] ), Number( markerSettings.scaledSize[1] ) ), //retina format
		origin: new google.maps.Point( Number( markerSettings.origin[0] ), Number( markerSettings.origin[1] ) ),
		anchor: new google.maps.Point( Number( markerSettings.anchor[0] ), Number( markerSettings.anchor[1] ) )
	};

    marker = new google.maps.Marker({
		position: latLng,
		map: map,
		optimized: false, //fixes markers flashing while bouncing
		title: decodeHtmlEntity( infoWindowData.store ),
		draggable: draggable,
		storeId: storeId,
		icon: mapIcon
	});	

	// Store the marker for later use.
	markersArray.push( marker );

    google.maps.event.addListener( marker, "click",( function( currentMap ) {
		return function() {
			
			// The start marker will have a store id of 0, all others won't.
			if ( storeId != 0 ) {

				// Check if streetview is available at the clicked location.
				if ( typeof wpslSettings.markerStreetView !== "undefined" && wpslSettings.markerStreetView == 1 ) {
					checkStreetViewStatus( latLng, function() {
						setInfoWindowContent( marker, createInfoWindowHtml( infoWindowData ), infoWindow, currentMap );
					});
				} else {
					setInfoWindowContent( marker, createInfoWindowHtml( infoWindowData ), infoWindow, currentMap );
				}
			} else {
				setInfoWindowContent( marker, wpslLabels.startPoint, infoWindow, currentMap );
			}

			google.maps.event.clearListeners( infoWindow, "domready" );
			
			google.maps.event.addListener( infoWindow, "domready", function() {
				infoWindowClickActions( marker, currentMap );
				checkMaxZoomLevel();
			});
		};
    }( map ) ) );
	
	// Only the start marker will be draggable.
	if ( draggable ) {
		google.maps.event.addListener( marker, "dragend", function( event ) {
			deleteOverlays( keepStartMarker );
			map.setCenter( event.latLng );
			reverseGeocode( event.latLng );
			findStoreLocations( event.latLng, resetMap, autoLoad = false, infoWindow );
		}); 
    }
}

/**
 * Decode HTML entities.
 * 
 * @link	https://gist.github.com/CatTail/4174511
 * @since	2.0.4
 * @param	{string} str The string to decode.
 * @returns {string} The string with the decoded HTML entities.
 */
function decodeHtmlEntity( str ) {
	if ( str ) {
		return str.replace( /&#(\d+);/g, function( match, dec) {
			return String.fromCharCode( dec );
		});
	}
};

// Check if we are using both the infobox for the info windows and have marker clusters.
if ( typeof wpslSettings.infoWindowStyle !== "undefined" && wpslSettings.infoWindowStyle == "infobox" && wpslSettings.markerClusters == 1 ) {
	var clusters, clusterLen, markerLen, i, j;
	
	/* 
	 * We need to listen to both zoom_changed and idle. 
	 * 
	 * If the zoom level changes, then the marker clusters either merges nearby 
	 * markers, or changes into individual markers. Which is the moment we 
	 * either show or hide the opened info window.
	 * 
	 * "idle" is necessary to make sure the getClusters() is up 
	 * to date with the correct cluster data.
	 */
	google.maps.event.addListener( map, "zoom_changed", function() {
		google.maps.event.addListenerOnce( map, "idle", function() {

			if ( typeof markerClusterer !== "undefined" ) {		
				clusters = markerClusterer.clusters_;

				if ( clusters.length ) {
					for ( i = 0, clusterLen = clusters.length; i < clusterLen; i++ ) {
						for ( j = 0, markerLen = clusters[i].markers_.length; j < markerLen; j++ ) {

							/* 
							 * Match the storeId from the cluster marker with the 
							 * marker id that was set when the info window was opened 
							 */
							if ( clusters[i].markers_[j].storeId == activeWindowMarkerId ) {

								/* 
								 * If there is a visible info window, but the markers_[j].map is null ( hidden ) 
								 * it means the info window belongs to a marker that is part of a marker cluster.
								 * 
								 * If that is the case then we hide the info window ( the individual marker isn't visible ).
								 *
								 * The default info window script handles this automatically, but the
								 * infobox library in combination with the marker clusters doesn't.
								 */
								if ( infoWindow.getVisible() && clusters[i].markers_[j].map === null ) {
									infoWindow.setVisible( false );
								} else if ( !infoWindow.getVisible() && clusters[i].markers_[j].map !== null ) {
									infoWindow.setVisible( true );
								}

								break;
							}
						}
					}
				}
			}
		});
	});
}

/**
 * Set the correct info window content for the marker.
 * 
 * @since	1.2.20
 * @param	{object} marker			   Marker data
 * @param	{string} infoWindowContent The infoWindow content
 * @param	{object} infoWindow		   The infoWindow object
 * @param	{object} currentMap		   The map object
 * @returns {void}
 */
function setInfoWindowContent( marker, infoWindowContent, infoWindow, currentMap ) {
	openInfoWindow.length = 0;
	
	infoWindow.setContent( infoWindowContent );
	infoWindow.open( currentMap, marker );
	
	openInfoWindow.push( infoWindow );

	/* 
	 * Store the marker id if both the marker clusters and the infobox are enabled.
	 * 
	 * With the normal info window script the info window is automatically closed 
	 * once a user zooms out, and the marker clusters are enabled, 
	 * but this doesn't happen with the infobox library. 
	 * 
	 * So we need to show/hide it manually when the user zooms out, 
	 * and for this to work we need to know which marker to target. 
	 */
	if ( typeof wpslSettings.infoWindowStyle !== "undefined" && wpslSettings.infoWindowStyle == "infobox" && wpslSettings.markerClusters == 1 ) {
		activeWindowMarkerId = marker.storeId;
		infoWindow.setVisible( true );
	}
}

/**
 * Handle clicks for the different info window actions like, 
 * direction, streetview and zoom here.
 * 
 * @since	1.2.20
 * @param	{object} marker		Holds the marker data
 * @param	{object} currentMap	The map object
 * @returns {void}
 */
function infoWindowClickActions( marker, currentMap ) {
	$( ".wpsl-info-actions a" ).on( "click", function( e ) {
		var maxZoom = Number( wpslSettings.autoZoomLevel );

		e.stopImmediatePropagation();
				
		if ( $( this ).hasClass( "wpsl-directions" ) ) {

			/* 
			 * Check if we need to show the direction on the map
			 * or send the users to maps.google.com 
			 */
			if ( wpslSettings.directionRedirect == 1 ) {
				return true;
			} else {
				renderDirections( $( this ) );
			}
		} else if ( $( this ).hasClass( "wpsl-streetview" ) ) {
			activateStreetView( marker, currentMap );
		} else if ( $( this ).hasClass( "wpsl-zoom-here" ) ) {
			currentMap.setCenter( marker.getPosition() );
			currentMap.setZoom( maxZoom );
		}
		
		return false;
	});
}

/**
 * Check if have reached the max auto zoom level.
 * 
 * If so we hide the 'Zoom here' text in the info window, 
 * otherwise we show it.
 * 
 * @since	2.0.0
 * @returns {void}
 */
function checkMaxZoomLevel() {
	var zoomLevel = map.getZoom();

	if ( zoomLevel >= wpslSettings.autoZoomLevel ) {
		$( ".wpsl-zoom-here" ).hide();
	} else {
		$( ".wpsl-zoom-here" ).show();
	}	
}
	
/**
 * Activate streetview for the clicked location.
 * 
 * @since	1.2.20
 * @param	{object} marker	    The current marker
 * @param	{object} currentMap The map object
 * @returns {void}
 */
function activateStreetView( marker, currentMap ) {
	var panorama = currentMap.getStreetView();
		panorama.setPosition( marker.getPosition() );
		panorama.setVisible( true );
				
	$( "#wpsl-map-controls" ).hide();
		
	StreetViewListener( panorama, currentMap );
}

/**
 * Listen for changes in the streetview visibility.
 * 
 * Sometimes the infowindow offset is incorrect after switching back from streetview.
 * We fix this by zooming in and out. If someone has a better fix, then let me know at
 * info at tijmensmit.com
 * 
 * @since	1.2.20
 * @param	{object} panorama   The streetview object
 * @param	{object} currentMap The map object
 * @returns {void}
 */
function StreetViewListener( panorama, currentMap ) {
	google.maps.event.addListener( panorama, "visible_changed", function() {
		if ( !panorama.getVisible() ) {
			var currentZoomLevel = currentMap.getZoom();
			
			$( "#wpsl-map-controls" ).show();
			
			currentMap.setZoom( currentZoomLevel-1 );
			currentMap.setZoom( currentZoomLevel );
		}
	});
}

/**
 * Check the streetview status.
 * 
 * Make sure that a streetview exists for 
 * the latlng for the open info window.
 * 
 * @since	1.2.20
 * @param	{object}   latLng The latlng coordinates
 * @param	{callback} callback
 * @returns {void}
 */
function checkStreetViewStatus( latLng, callback ) {
	var service = new google.maps.StreetViewService();

	service.getPanoramaByLocation( latLng, 50, function( result, status ) {
		streetViewAvailable = ( status == google.maps.StreetViewStatus.OK ) ? true : false;	
		callback();
	});
}

/**
 * Helper methods for the underscore templates.
 * 
 * @link	 http://underscorejs.org/#template
 * @requires underscore.js
 * @todo move it to another JS file to make it accessible for add-ons?
 * @since	 2.0.0
 */
var templateHelpers = {
	/**
	 * Make the phone number clickable if we are dealing with a mobile useragent.
	 * 
	 * @since	1.2.20
	 * @param	{string} phoneNumber The phone number
	 * @returns {string} phoneNumber Either just the plain number, or with a link wrapped around it with tel:
	 */
	formatPhoneNumber: function( phoneNumber ) {
		if ( ( wpslSettings.phoneUrl == 1 ) && ( checkMobileUserAgent() ) || wpslSettings.clickableDetails == 1 ) {
			phoneNumber = "<a href='tel:" + templateHelpers.formatClickablePhoneNumber( phoneNumber ) + "'>" + phoneNumber + "</a>";
		}

		return phoneNumber;
	},
	/**
	 * Replace spaces - . and () from phone numbers. 
	 * Also if the number starts with a + we check for a (0) and remove it.
	 * 
	 * @since	1.2.20
	 * @param	{string} phoneNumber The phone number
	 * @returns {string} phoneNumber The 'cleaned' number
	 */
	formatClickablePhoneNumber: function( phoneNumber ) {
		if ( ( phoneNumber.indexOf( "+" ) != -1 ) && ( phoneNumber.indexOf( "(0)" ) != -1 ) ) {
			phoneNumber = phoneNumber.replace( "(0)", "" );
		}

		return phoneNumber.replace( /(-| |\(|\)|\.|)/g, "" );	
	},
    /**
	 * Check if we need to make the email address clickable.
	 *
	 * @since 2.2.13
     * @param   {string} email The email address
	 * @returns {string} email Either the normal email address, or the clickable version.
     */
	formatEmail: function( email ) {
        if ( wpslSettings.clickableDetails == 1 ) {
            email = "<a href='mailto:" + email + "'>" + email + "</a>";
        }

		return email;
	},
	/**
	 * Create the html for the info window action.
	 * 
	 * @since	2.0.0
	 * @param	{string} id		The store id
	 * @returns {string} output The html for the info window actions
	 */
	createInfoWindowActions: function( id ) {
		var output, 
			streetView = "",
			zoomTo	   = "";

		if ( $( "#wpsl-gmap" ).length ) {
			if ( streetViewAvailable ) {
				streetView = "<a class='wpsl-streetview' href='#'>" + wpslLabels.streetView + "</a>";
			}

			if ( wpslSettings.markerZoomTo == 1 ) {
				zoomTo = "<a class='wpsl-zoom-here' href='#'>" + wpslLabels.zoomHere + "</a>";
			}

			output = "<div class='wpsl-info-actions'>" + templateHelpers.createDirectionUrl( id ) + streetView + zoomTo + "</div>";	
		}

		return output;
	},
	/**
	 * Create the url that takes the user to the maps.google.com page 
	 * and shows the correct driving directions.
	 * 
	 * @since	1.0.0
	 * @param	{string} id			  The store id
	 * @returns {string} directionUrl The full maps.google.com url with the encoded start + end address
	 */
	createDirectionUrl: function( id ) {
		var directionUrl, destinationAddress, zip,
			url = {};

		if ( wpslSettings.directionRedirect == 1 ) {

			// If we somehow failed to determine the start address, just set it to empty.
			if ( typeof startAddress === "undefined" ) {
				startAddress = "";
			}
			
			url.target = "target='_blank'";
			
			// If the id exists the user clicked on a marker we get the direction url from the search results.
			if ( typeof id !== "undefined" ) {
				url.src = $( "[data-store-id=" + id + "] .wpsl-directions" ).attr( "href" );
			} else {

				// Only add a , after the zip if the zip value exists.
				if ( this.zip ) {
					zip = this.zip + ", ";
				} else {
					zip = "";
				}

				destinationAddress = this.address + ", " + this.city + ", " + zip + this.country;

				url.src = "https://www.google.com/maps/dir/?api=1&origin=" + templateHelpers.rfc3986EncodeURIComponent( startAddress ) + "&destination=" + templateHelpers.rfc3986EncodeURIComponent( destinationAddress ) + "&travelmode=" + wpslSettings.directionsTravelMode.toLowerCase() + "";
			}
		} else {
			url = {
				src: "#",
				target: ""
			};
		}	

		directionUrl = "<a class='wpsl-directions' " + url.target + " href='" + url.src + "'>" + wpslLabels.directions + "</a>";

		return directionUrl;
	},
	/**
	 * Make the URI encoding compatible with RFC 3986.
	 * 
	 * !, ', (, ), and * will be escaped, otherwise they break the string.
	 * 
	 * @since	1.2.20
	 * @param	{string} str The string to encode
	 * @returns {string} The encoded string
	 */
	rfc3986EncodeURIComponent: function( str ) {  
		return encodeURIComponent( str ).replace( /[!'()*]/g, escape );  
	}
};

/**
 * Create the HTML template used in the info windows on the map.
 * 
 * @since	1.0.0
 * @param	{object} infoWindowData	The data that is shown in the info window (address, url, phone etc)
 * @returns {string} windowContent	The HTML content that is placed in the info window
 */
function createInfoWindowHtml( infoWindowData ) {
	var windowContent, template;

	if ( $( "#wpsl-base-gmap_0" ).length ) {
		template = $( "#wpsl-cpt-info-window-template" ).html();
	} else {
		template = $( "#wpsl-info-window-template" ).html();
	}

	windowContent = _.template( template )( infoWindowData ); //see http://underscorejs.org/#template

	return windowContent;
}

/**
 * Zoom the map so that all markers fit in the window.
 * 
 * @since	1.0.0
 * @returns {void}
 */
function fitBounds() {
    var i, markerLen, 
		maxZoom = Number( wpslSettings.autoZoomLevel ),
		bounds  = new google.maps.LatLngBounds();
		
    // Make sure we don't zoom to far.
    attachBoundsChangedListener( map, maxZoom );

    for ( i = 0, markerLen = markersArray.length; i < markerLen; i++ ) {
		bounds.extend ( markersArray[i].position );
    }

    map.fitBounds( bounds );
}

/**
 * Remove all existing markers from the map.
 * 
 * @since	1.0.0
 * @param	{boolean} keepStartMarker Whether or not to keep the start marker while removing all the other markers from the map
 * @returns {void}
 */
function deleteOverlays( keepStartMarker ) {
	var markerLen, i;
	
    directionsDisplay.setMap( null );
	
    // Remove all the markers from the map, and empty the array.
    if ( markersArray ) {
		for ( i = 0, markerLen = markersArray.length; i < markerLen; i++ ) {
			
			// Check if we need to keep the start marker, or remove everything.
			if ( keepStartMarker ) {
				if ( markersArray[i].draggable != true ) {
					markersArray[i].setMap( null );
				} else {
					startMarkerData = markersArray[i];
				}
			} else {
				markersArray[i].setMap( null );
			}
		}

		markersArray.length = 0;
    }
	
	// If marker clusters exist, remove them from the map.
	if ( markerClusterer ) {
		markerClusterer.clearMarkers();
	}
}

/**
 * Handle the geocode errors.
 * 
 * @since	1.0.0
 * @param   {string} status Contains the error code
 * @returns {void}
 */
function geocodeErrors( status ) {
    var msg;

    switch ( status ) {
		case "ZERO_RESULTS":
			msg = wpslLabels.noResults;
			break;	
		case "OVER_QUERY_LIMIT":
			msg = wpslLabels.queryLimit;
			break;	
		default:
			msg = wpslLabels.generalError;
			break;
    }

    alert( msg );	
}

/**
 * Handle the driving direction errors.
 * 
 * @since   1.2.20
 * @param   {string} status Contains the error code
 * @returns {void}
 */
function directionErrors( status ) {
    var msg;

    switch ( status ) {
		case "NOT_FOUND":
		case "ZERO_RESULTS":
			msg = wpslLabels.noDirectionsFound;
			break;	
		case "OVER_QUERY_LIMIT":
			msg = wpslLabels.queryLimit;
			break;
		default:
			msg = wpslLabels.generalError;
			break;
    }

    alert( msg );	
}

$( "#wpsl-stores" ).on( "click", ".wpsl-store-details", function() {	
	var i, len,
		$parentLi = $( this ).parents( "li" ),
		storeId   = $parentLi.data( "store-id" );

	// Check if we should show the 'more info' details.
	if ( wpslSettings.moreInfoLocation == "info window" ) {
		for ( i = 0, len = markersArray.length; i < len; i++ ) {
			if ( markersArray[i].storeId == storeId ) {
				google.maps.event.trigger( markersArray[i], "click" );
			}
		}
	} else {
		
		// Check if we should set the 'more info' item to active or not.
		if ( $parentLi.find( ".wpsl-more-info-listings" ).is( ":visible" ) ) {
			$( this ).removeClass( "wpsl-active-details" );
		} else {
			$( this ).addClass( "wpsl-active-details" );
		}		
		
		$parentLi.siblings().find( ".wpsl-store-details" ).removeClass( "wpsl-active-details" );
		$parentLi.siblings().find( ".wpsl-more-info-listings" ).hide();
		$parentLi.find( ".wpsl-more-info-listings" ).toggle();
	}

	/* 
	 * If we show the store listings under the map, we do want to jump to the 
	 * top of the map to focus on the opened infowindow 
	 */
	if ( wpslSettings.templateId != "default" || wpslSettings.moreInfoLocation == "store listings" ) {
		return false;
	}
});

/**
 * Create the styled dropdown filters.
 * 
 * Inspired by https://github.com/patrickkunka/easydropdown
 * 
 * @since	1.2.24
 * @returns {void}
 */
function createDropdowns() {
	var maxDropdownHeight = Number( wpslSettings.maxDropdownHeight );
		
	$( ".wpsl-dropdown" ).each( function( index ) {
		var	active, maxHeight, $this = $( this );
		
		$this.$dropdownWrap = $this.wrap( "<div class='wpsl-dropdown'></div>" ).parent();	
		$this.$selectedVal  = $this.val();							
		$this.$dropdownElem = $( "<div><ul/></div>" ).appendTo( $this.$dropdownWrap );
		$this.$dropdown     = $this.$dropdownElem.find( "ul" );
		$this.$options 	  	= $this.$dropdownWrap.find( "option" );
		
		// Hide the original <select> and remove the css class.
		$this.hide().removeClass( "wpsl-dropdown" );
		
		// Loop over the options from the <select> and move them to a <li> instead.
		$.each( $this.$options, function() {
			if ( $( this ).val() == $this.$selectedVal ) {
				active = 'class="wpsl-selected-dropdown"';
			} else {
				active = '';
			}

			$this.$dropdown.append( "<li data-value=" + $( this ).val() + " " + active + ">" + $( this ).text() + "</li>" );
		});	
		
		$this.$dropdownElem.before( "<span data-value=" + $this.find( ":selected" ).val() + " class='wpsl-selected-item'>" + $this.find( ":selected" ).text() + "</span>" );
		$this.$dropdownItem = $this.$dropdownElem.find( "li" );
		
		// Listen for clicks on the 'wpsl-dropdown' div.
		$this.$dropdownWrap.on( "click", function( e ) {

			// Check if we only need to close the current open dropdown.
			if ( $( this ).hasClass( "wpsl-active" ) ) {
				$( this ).removeClass( "wpsl-active" );

				return;
			}

			closeAllDropdowns();

			$( this ).toggleClass( "wpsl-active" );
			maxHeight = 0;

			// Either calculate the correct height for the <ul>, or set it to 0 to hide it.
			if ( $( this ).hasClass( "wpsl-active" ) ) {
				$this.$dropdownItem.each( function( index ) {
					maxHeight += $( this ).outerHeight();
				});

				$this.$dropdownElem.css( "height", maxHeight + 2 + "px" );
			} else {
				$this.$dropdownElem.css( "height", 0 );
			}

			// Check if we need to enable the scrollbar in the dropdown filter.
			if ( maxHeight > maxDropdownHeight ) {
				$( this ).addClass( "wpsl-scroll-required" );
				$this.$dropdownElem.css( "height", ( maxDropdownHeight ) + "px" );
			}

			e.stopPropagation();
		});
		
		// Listen for clicks on the individual dropdown items.
		$this.$dropdownItem.on( "click", function( e ) {
			
			// Set the correct value as the selected item.
			$this.$dropdownWrap.find( $( ".wpsl-selected-item" ) ).html( $( this ).text() ).attr( "data-value", $( this ).attr( "data-value" ) );	

			// Apply the class to the correct item to make it bold.
			$this.$dropdownItem.removeClass( "wpsl-selected-dropdown" );
			$( this ).addClass( "wpsl-selected-dropdown" );
			
			closeAllDropdowns();
			
			e.stopPropagation();
		});
	});	
	
	$( document ).click( function() {
		closeAllDropdowns();
	});
}

/**
 * Close all the dropdowns.
 * 
 * @since	1.2.24
 * @returns {void}
 */
function closeAllDropdowns() {
	$( ".wpsl-dropdown" ).removeClass( "wpsl-active" );
	$( ".wpsl-dropdown div" ).css( "height", 0 );	
}

/**
 * Check if the user submitted a search through a search widget.
 *
 * @since	2.1.0
 * @returns {void}
 */
function checkWidgetSubmit() {
	if ( $( ".wpsl-search" ).hasClass( "wpsl-widget" ) ) {
		$( "#wpsl-search-btn" ).trigger( "click" );
		$( ".wpsl-search" ).removeClass( "wpsl-widget" );
	}
}

/**
 * Check if we need to run the code to prevent Google Maps
 * from showing up grey when placed inside one or more tabs.
 *
 * @since 2.2.10
 * @return {void}
 */
function maybeApplyTabFix() {
	var mapNumber, len;

	if ( _.isArray( wpslSettings.mapTabAnchor ) ) {
		for ( mapNumber = 0, len = mapsArray.length; mapNumber < len; mapNumber++ ) {
			fixGreyTabMap( mapsArray[mapNumber], wpslSettings.mapTabAnchor[mapNumber], mapNumber );
		}
	} else if ( $( "a[href='#" + wpslSettings.mapTabAnchor + "']" ).length ) {
		fixGreyTabMap( map, wpslSettings.mapTabAnchor );
	}
}

/**
 * This code prevents the map from showing a large grey area if
 * the store locator is placed in a tab, and that tab is actived.
 *
 * The default map anchor is set to 'wpsl-map-tab', but you can
 * change this with the 'wpsl_map_tab_anchor' filter.
 *
 * Note: If the "Attempt to auto-locate the user" option is enabled,
 * and the user quickly switches to the store locator tab, before the
 * Geolocation timeout is reached, then the map is sometimes centered in the ocean.
 *
 * I haven't really figured out why this happens. The only option to fix this
 * is to simply disable the "Attempt to auto-locate the user" option if
 * you use the store locator in a tab.
 *
 * @since   2.2.10
 * @param   {object} currentMap	  The map object from the current map
 * @param   {string} mapTabAnchor The anchor used in the tab that holds the map
 * @param 	(int) 	 mapNumber    Map number
 * @link    http://stackoverflow.com/questions/9458215/google-maps-not-working-in-jquery-tabs
 * @returns {void}
 */
function fixGreyTabMap( currentMap, mapTabAnchor, mapNumber ) {
    var mapZoom, mapCenter, maxZoom, bounds, tabMap,
        returnBool = Number( wpslSettings.mapTabAnchorReturn ) ? true : false,
		$wpsl_tab  = $( "a[href='#" + mapTabAnchor + "']" );

    if ( typeof currentMap.maxZoom !== "undefined" ) {
        maxZoom = currentMap.maxZoom;
	} else {
        maxZoom = Number( wpslSettings.autoZoomLevel );
	}

	/*
	 * We need to do this to prevent the map from flashing if
	 * there's only a single marker on the first click on the tab.
	 */
	if ( typeof mapNumber !== "undefined" && mapNumber == 0 ) {
        $wpsl_tab.addClass( "wpsl-fitbounds" );
	}

	$wpsl_tab.on( "click", function() {
		setTimeout( function() {
            if ( typeof currentMap.map !== "undefined" ) {
                bounds = currentMap.bounds;
                tabMap = currentMap.map;
            } else {
            	tabMap = currentMap;
			}

            mapZoom   = tabMap.getZoom();
            mapCenter = tabMap.getCenter();

			google.maps.event.trigger( tabMap, "resize" );

			if ( !$wpsl_tab.hasClass( "wpsl-fitbounds" ) ) {

                //Make sure fitBounds doesn't zoom past the max zoom level.
                attachBoundsChangedListener( tabMap, maxZoom );

                tabMap.setZoom( mapZoom );
				tabMap.setCenter( mapCenter );

                if ( typeof bounds !== "undefined" ) {
                    tabMap.fitBounds( bounds );
                } else {
                	fitBounds();
				}

				$wpsl_tab.addClass( "wpsl-fitbounds" );
            }
        }, 50 );

        return returnBool;
    });
}

/**
 * Add the bounds_changed event listener to the map object
 * to make sure we don't zoom past the max zoom level.
 *
 * @since 2.2.10
 * @param object The map object to attach the event listener to
 * @returns {void}
 */
function attachBoundsChangedListener( map, maxZoom ) {
    google.maps.event.addListenerOnce( map, "bounds_changed", function() {
        google.maps.event.addListenerOnce( map, "idle", function() {
            if ( this.getZoom() > maxZoom ) {
                this.setZoom( maxZoom );
            }
        });
    });
}

/**
 * Handle keyboard submits when the autocomplete option is enabled.
 *
 * If we don't do this, then the search will break the second time
 * the user makes a search, selects the item with the keyboard
 * and submits it with the enter key.
 *
 * @since 2.2.20
 * @returns {void}
 */
function keyboardAutoCompleteSubmit() {
	$( "#wpsl-search-input" ).keypress( function( e ) {

		if ( e.which == 13 ) {
			resetSearchResults();
			codeAddress( infoWindow );

			return false;
		}
	});
}

/**
 * Reset all elements before a search is made.
 *
 * @since 2.2.20
 * @returns {void}
 */
function resetSearchResults() {
	var keepStartMarker = false;

	$( "#wpsl-result-list ul" ).empty();
	$( "#wpsl-stores" ).show();
	$( ".wpsl-direction-before, .wpsl-direction-after" ).remove();
	$( "#wpsl-direction-details" ).hide();

	resetMap = false;

	// Force the open InfoBox info window to close.
	closeInfoBoxWindow();

	deleteOverlays( keepStartMarker );
	deleteStartMarker();
}

});