Last active
August 31, 2016 03:13
-
-
Save smartcatdev/b8589e38c2c2ececce171ddadc2217be to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
(function () { | |
/**************************************************************************** | |
* GLOBAL CONSTANTS, ASSETS, AND TEMPLATES | |
****************************************************************************/ | |
/* global google autoComplete */ | |
/* eslint no-console: 0 */ | |
var LOG_PREFIX = 'ts-signup: '; | |
var DEBUGGIN = false; | |
var BUSINESS = { | |
api_type: 'establishment', // Used by Places API, don't change | |
not_found_flag: 'end-of-suggestions', | |
not_found_template: '<div class="autocomplete-suggestion end-of-suggestions" data-val="#{DATA_VAL}">Don\'t see your business?</div>' | |
}; | |
var ADDRESS = { | |
api_type: 'address', // Used by Places API, don't change | |
not_found_flag: 'end-of-addresses', | |
not_found_template: '<div class="autocomplete-suggestion end-of-addresses" data-val="#{DATA_VAL}">Don\'t see your address?</div>' | |
}; | |
var CDN_PREFIX = 'https://gist.githubusercontent.com/smartcatdev/c150eef5021e497158f7fa973f0ce864/raw/51d2f3f17d273d210eb1bd9dba7c3cbfc407ecbf/' // Replace with local path or empty string to test | |
// Vanilla JavaScript completion suggester | |
var AUTOCOMPLETE_JS = '//rawgit.com/smartcatdev/c150eef5021e497158f7fa973f0ce864/raw/51d2f3f17d273d210eb1bd9dba7c3cbfc407ecbf/autocomplete.min.js'; // Shouldn't be needed when we're in prod due to concatenation | |
var AUTOCOMPLETE_CSS = '//rawgit.com/smartcatdev/cd96ccda533a1deadcad95c50e1f2c33/raw/2447ea6cce21588aaf7cf54e20858906fe22b932/auto-complete.css'; | |
// Backend services for business lookup and autocomplete (singletons) | |
var GOOGLE_API_KEY = 'AIzaSyC16WW-BiwHT3k4QaNyO92o-OrfeqI87Ds'; | |
var GOOGLE_API = 'https://maps.googleapis.com/maps/api/js?v=3.exp&libraries=places,geometry&key=' + GOOGLE_API_KEY; | |
var autocompleteService, placesService; | |
var TOWNSQUARED_API = 'https://townsquared.com/api/'; | |
var TS_FIND_ENDPOINT = TOWNSQUARED_API + 'v3/neighborhoods/find'; | |
var SIGNUP_TEMPLATE = "<form action='https:/townsquared.com/register'>" + | |
"<input type='text' name='name' class='width-3' placeholder='Enter your business name...' disabled />" + | |
"<input type='hidden' name='address' class='width-3' placeholder='Your business address' />" + | |
"<input type='hidden' name='apt' class='width-1' placeholder='Apt' />" + | |
"<input type='hidden' name='city' class='width-2' placeholder='City' />" + | |
"<input type='hidden' name='state' class='width-1' placeholder='State' />" + | |
"<input type='hidden' name='zip' class='width-1' placeholder='Zip' />" + | |
"<input type='hidden' name='business' />" + | |
"<button type='submit' class='width-1' disabled>Get Started</button>" + | |
"</form>"; | |
/**************************************************************************** | |
* MAIN | |
****************************************************************************/ | |
loadSegment(); // asynchronous, calls are cached until library is loaded | |
loadAutoComplete(function () { | |
loadGoogleAPI(function () { | |
autocompleteService = new google.maps.places.AutocompleteService(); | |
placesService = new google.maps.places.PlacesService(document.createElement('div')); | |
// TODO: handle more than one div | |
var div = document.getElementsByClassName('ts-signup')[0]; | |
div.innerHTML = SIGNUP_TEMPLATE; | |
new Signup(div); | |
}); | |
}); | |
function Signup(div) { | |
// Has the sure clicked "Can't find your business?" | |
this.manualBusinessInput = false; | |
var inputs = this.inputs = { | |
name: div.querySelector('input[name="name"]'), | |
address: div.querySelector('input[name="address"]'), | |
apt: div.querySelector('input[name="apt"]'), | |
city: div.querySelector('input[name="city"]'), | |
state: div.querySelector('input[name="state"]'), | |
zip: div.querySelector('input[name="zip"]'), | |
json: div.querySelector('input[name="business"]') | |
}; | |
this.business = new Business(); | |
var submit = this.submitButton = div.getElementsByTagName('button')[0]; | |
// "Enter your business..." | |
new autoComplete({ | |
selector: inputs.name, | |
minChars: 1, | |
delay: 200, | |
source: query.bind(undefined, BUSINESS), | |
renderItem: render.bind(undefined, BUSINESS), | |
onSelect: updateBusiness.bind(this) | |
}); | |
// "Your business address" | |
new autoComplete({ | |
selector: inputs.address, | |
minChars: 1, | |
delay: 200, | |
source: query.bind(undefined, ADDRESS), | |
renderItem: render.bind(undefined, ADDRESS), | |
onSelect: updateAddress.bind(this) | |
}); | |
// Log interactions with inputs | |
inputs.name.onfocus = reportFocus; | |
// On submit, update the business object and set the value of the business | |
// param as stringified object. | |
submit.onclick = handleSubmit.bind(this); | |
// Everything set up and good to go, allow user input | |
inputs.name.disabled = submit.disabled = false; | |
} | |
/**************************************************************************** | |
* SIGNUP HELPERS | |
****************************************************************************/ | |
/*** AUTOCOMPLETE **********************************************************/ | |
function query(type, term, callback) { | |
var options = { input: term, types: [type.api_type] }; | |
autocompleteService.getPlacePredictions(options, function (results) { | |
// If zero results, results will be null | |
results = results !== null ? results : []; | |
results.push(type.not_found_flag); | |
callback(results); | |
}); | |
} | |
function render(type, item, search) { | |
if (item === type.not_found_flag) { | |
return type.not_found_template.replace('#{DATA_VAL}', search); | |
} | |
// Santizes search string for regex | |
search = search.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&'); | |
// Allows us to bold characters that match search | |
var re = new RegExp('(' + search.split(' ').join('|') + ')', 'gi'); | |
var termVals = [ item.terms[0].value ]; | |
if (item.terms.length > 1) { termVals.push(item.terms[1].value); } | |
if (item.terms.length > 2) { termVals.push(item.terms[2].value); } | |
var dataVal = termVals.join(', '); | |
return '<div class="autocomplete-suggestion" ' + | |
'place-id="' + item.place_id + '" ' + | |
'data-val="' + dataVal + '">' + | |
item.description.replace(re, '<b>$1</b>') + '</div>'; | |
} | |
/*** SIGNUP UPDATE METHODS *************************************************/ | |
function updateBusiness(event, term, item) { | |
if (item.classList.contains(BUSINESS.not_found_flag)) { | |
this.manualBusinessInput = true; | |
this.renderManualBusinessInputs(); | |
} else { // Found business, hit Places API for details | |
this.business.placeId = item.getAttribute('place-id'); | |
var request = { placeId: this.business.placeId }; | |
var signup = this; | |
placesService.getDetails(request, function (details, status) { | |
if (status === google.maps.places.PlacesServiceStatus.OK) { | |
signup.business.name = details.name; | |
signup.business.website = details.website; | |
signup.business.updateAddressDetails(details, signup.inputs); | |
signup.submitButton.click(); | |
} | |
}); | |
} | |
} | |
function updateAddress(event, term, item) { | |
if (item.classList.contains(ADDRESS.not_found_flag)) { | |
this.renderManualAddressInputs(); | |
} else { // Found address, hit Places API for details | |
this.business.placeId = item.getAttribute('place-id'); | |
var request = { placeId: this.business.placeId }; | |
var signup = this; | |
placesService.getDetails(request, function (details, status) { | |
if (status === google.maps.places.PlacesServiceStatus.OK) { | |
signup.business.updateAddressDetails(details, signup.inputs); | |
signup.renderManualAddressInputs(); | |
} | |
}); | |
} | |
} | |
/*** DOM *******************************************************************/ | |
/* Validate and handle submission | |
* TODO: split out validation from submission, DOM vs model */ | |
function handleSubmit(event) { | |
// Don't actually click unless address is valid | |
event.preventDefault(); | |
event.stopPropagation(); | |
if (this.inputs.name.value === '') { | |
this.renderError('Please find your business or fill in your information manually.'); | |
} else if (this.inputs.address.value === '') { | |
this.renderError('Please enter your address.'); | |
this.manualBusinessInput = true; | |
this.renderManualBusinessInputs(); | |
} else if (this.inputs.city.value === '') { | |
this.renderError('Please enter your city.'); | |
} else if (this.inputs.state.value === '') { | |
this.renderError('Please enter your state.'); | |
} else if (this.inputs.zip.value === '') { | |
this.renderError('Please enter your postal code'); | |
} else { | |
// Blindly pull from inputs as the update functions update the dom. | |
this.business.address = this.inputs.address.value; | |
this.business.apartment_number = this.inputs.apt.value; | |
this.business.city = this.inputs.city.value; | |
this.business.state = this.inputs.state.value; | |
this.business.postal_code = this.inputs.zip.value; | |
var payload = encodeGetParams(this.business); | |
var signup = this; | |
getCORS(TS_FIND_ENDPOINT + payload, function (request) { | |
var statusCode = request.srcElement.status; | |
var response = JSON.parse(request.currentTarget.response || | |
request.target.responseText); | |
if (statusCode === 200) { | |
// Send submission event | |
analytics.track('standalone-signup.submit', { | |
manual: signup.manualBusinessInput, | |
business: signup.business.name, | |
invite_code: signup.business.invite_code, | |
invite_id: signup.business.invite_id | |
}); | |
// "business" param i.e. townsquared.com/register?business=... | |
// TODO: Refactor into address, apt, city, state, zip | |
signup.inputs.json.value = JSON.stringify(signup.business); | |
signup.inputs.name.parentElement.submit(); | |
} else { | |
signup.renderError(errorMessage(response.errors, signup.business)); | |
} | |
}); | |
} | |
} | |
Signup.prototype.renderError = function (message) { | |
var button = this.submitButton; | |
if (button.previousElementSibling && | |
button.previousElementSibling.tagName.toLowerCase() === 'input') { | |
var flashMsg = document.createElement('span'); | |
flashMsg.classList.add('submission-error'); | |
flashMsg.innerHTML = message; | |
button.parentElement.insertBefore(flashMsg, button); | |
} else { | |
button.previousSibling.innerHTML = message; | |
} | |
}; | |
Signup.prototype.renderManualBusinessInputs = function () { | |
this.inputs.name.classList.remove('width-3'); | |
this.inputs.name.classList.add('width-4'); | |
this.submitButton.classList.remove('width-1'); | |
this.submitButton.classList.add('width-4'); | |
showInput(this.inputs.address); | |
showInput(this.inputs.apt); | |
}; | |
Signup.prototype.renderManualAddressInputs = function () { | |
showInput(this.inputs.city); | |
showInput(this.inputs.state); | |
showInput(this.inputs.zip); | |
}; | |
/**************************************************************************** | |
* ANALYTICS | |
****************************************************************************/ | |
// Singleton that keeps track of all analytics events. Allows us to block | |
// until all events are sent. | |
function reportFocus(e) { | |
analytics.track('standalone-signup.input.' + e.target.name + '.focus'); | |
} | |
/**************************************************************************** | |
* BUSINESS OBJECT FOR MAINTAINING STATE AND DOM | |
****************************************************************************/ | |
function Business() { // This is seralized and sent to the register page | |
this.placeId = ''; | |
this.name = ''; | |
this.website = ''; | |
this.address = ''; | |
this.apartment_number = ''; | |
this.city = ''; | |
this.state = ''; | |
this.postal_code = ''; | |
this.invite_code = getParam('invite_code'); | |
this.invite_id = getParam('invite_id'); | |
} | |
Business.prototype.clearAddress = function (inputs) { | |
this.address = inputs.address.value = ''; | |
this.apartment_number = inputs.apt.value = ''; | |
this.city = inputs.city.value = ''; | |
this.state = inputs.state.value = ''; | |
this.postal_code = inputs.zip.value = ''; | |
}; | |
Business.prototype.updateAddressDetails = function (details, inputs) { | |
this.clearAddress(inputs); | |
var business = this; | |
details.address_components.forEach( function (component) { | |
if (component.types.indexOf('subpremise') > -1) { | |
business.apartment_number = component.long_name; | |
inputs.apt.value = component.long_name; | |
} | |
else if (component.types.indexOf('street_number') > -1) { | |
business.address = component.long_name + ' '; | |
} | |
else if (component.types.indexOf('route') > -1) { | |
business.address += component.long_name; | |
inputs.address.value = business.address; | |
} | |
else if (component.types.indexOf('locality') > -1) { | |
business.city = component.long_name; | |
inputs.city.value = component.long_name; | |
} | |
else if (component.types.indexOf('sublocality') > -1) { | |
business.city = business.city || component.long_name; | |
inputs.city.value = business.city; | |
} | |
else if (component.types.indexOf('administrative_area_level_1') > -1) { | |
business.state = component.short_name; | |
inputs.state.value = component.short_name; | |
} | |
else if (component.types.indexOf('postal_code') > -1) { | |
business.postal_code = component.short_name; | |
inputs.zip.value = component.short_name; | |
} | |
}); | |
}; | |
/**************************************************************************** | |
* LOAD FUNCTIONS FOR EXTERNAL SCRIPTS | |
****************************************************************************/ | |
function loadAutoComplete(cb) { | |
if (typeof autoComplete === 'undefined') { | |
log('autoComplete not found, loading...'); | |
var link = document.createElement( 'link' ); | |
link.type = 'text/css'; | |
link.rel = 'stylesheet'; | |
link.href = AUTOCOMPLETE_CSS; | |
document.getElementsByTagName('head')[0].appendChild(link); | |
importScript(AUTOCOMPLETE_JS, function() { cb(); }); | |
} else { | |
log('Found autoComplete :D'); | |
cb(); | |
} | |
} | |
function loadGoogleAPI(cb) { | |
if (typeof google === 'undefined' || typeof google.maps === 'undefined') { | |
log('Google Maps/Places API not found, loading...'); | |
importScript(GOOGLE_API, function() { cb(); }); | |
} else { | |
log('Found Google Maps/Places API :D'); | |
cb(); | |
} | |
} | |
function loadSegment(cb) { | |
// Create a queue, but don't obliterate an existing one! | |
var analytics = window.analytics = window.analytics || []; | |
// If the real analytics.js is already on the page return. | |
if (analytics.initialize) return; | |
// If the snippet was invoked already show an error. | |
if (analytics.invoked) { | |
if (window.console && console.error) { | |
console.error('Segment snippet included twice.'); | |
} | |
return; | |
} | |
// Invoked flag, to make sure the snippet | |
// is never invoked twice. | |
analytics.invoked = true; | |
// A list of the methods in Analytics.js to stub. | |
analytics.methods = [ | |
'track', | |
'page', | |
]; | |
// Define a factory to create stubs. These are placeholders | |
// for methods in Analytics.js so that you never have to wait | |
// for it to load to actually record data. The `method` is | |
// stored as the first argument, so we can replay the data. | |
analytics.factory = function(method){ | |
return function(){ | |
var args = Array.prototype.slice.call(arguments); | |
args.unshift(method); | |
analytics.push(args); | |
return analytics; | |
}; | |
}; | |
// For each of our methods, generate a queueing stub. | |
for (var i = 0; i < analytics.methods.length; i++) { | |
var key = analytics.methods[i]; | |
analytics[key] = analytics.factory(key); | |
} | |
// Define a method to load Analytics.js from our CDN, | |
// and that will be sure to only ever load it once. | |
analytics.load = function(key){ | |
// Create an async script element based on your key. | |
var script = document.createElement('script'); | |
script.type = 'text/javascript'; | |
script.async = true; | |
script.src = ('https:' === document.location.protocol | |
? 'https://' : 'http://') | |
+ 'cdn.segment.com/analytics.js/v1/' | |
+ key + '/analytics.min.js'; | |
// Insert our script next to the first script element. | |
var first = document.getElementsByTagName('script')[0]; | |
first.parentNode.insertBefore(script, first); | |
}; | |
// Add a version to keep track of what's in the wild. | |
analytics.SNIPPET_VERSION = '3.1.0'; | |
// Load Analytics.js with your key, which will automatically | |
// load the tools you've enabled for your account. Boosh! | |
analytics.load("phSIBdb7QwurkzYzaLAs2BZKHewlDDid"); | |
// Make the first page call to load the integrations. If | |
// you'd like to manually name or tag the page, edit or | |
// move this call however you'd like. | |
analytics.page(); | |
} | |
/**************************************************************************** | |
* UTILITIES | |
****************************************************************************/ | |
function errorMessage(error, business) { | |
// TODO: Error string templates should be constants | |
var ask = '. Please double check and try again!'; | |
switch (error.code) { | |
case 'MISSING_PARAM': | |
return 'Missing ' + error.field + ' field.'; | |
case 'CITY_NOT_IDENTIFIED': | |
return 'We couuldn\'t find ' + business.city + ask; | |
case 'ADDRESS_NOT_IDENTIFIED': | |
return 'We couuldn\'t find ' + addressString(business) + ask; | |
case 'ADDRESS_NOT_MAPPED': | |
return 'We couldn\t find the neighorhood for ' + addressString(business) + ask; | |
default: | |
return 'We couldn\'t find your address' + ask; | |
} | |
} | |
function addressString(b) { | |
return b.address + ', ' + b.city + ', ' + b.state + b.postal_code; | |
} | |
/*** DOM *******************************************************************/ | |
function showInput(element) { | |
element.type = 'text'; | |
} | |
function importScript(src, callback) { | |
var scriptElement = document.createElement('script'); | |
scriptElement.type = 'text\/javascript'; | |
scriptElement.onerror = loadError; | |
if (callback) { scriptElement.onload = callback; } | |
document.body.appendChild(scriptElement); | |
scriptElement.src = src; | |
} | |
function loadError (oError) { | |
throw new URIError('The script ' + oError.target.src + ' is not accessible.'); | |
} | |
/*** AJAX ******************************************************************/ | |
// https://plainjs.com/javascript/ajax/making-cors-ajax-get-requests-54/ | |
function getCORS(url, success) { | |
var xhr = new XMLHttpRequest(); | |
xhr.open('GET', url); | |
xhr.onload = success; | |
xhr.send(); | |
return xhr; | |
} | |
// http://stackoverflow.com/a/12040639 | |
function encodeGetParams(data) { | |
return '?' + Object.keys(data).map(function(key) { | |
return [key, data[key]].map(encodeURIComponent).join('='); | |
}).join('&'); | |
} | |
function getParam(name, url) { | |
if (!url) url = window.location.href; | |
name = name.replace(/[\[\]]/g, '\\$&'); | |
var regex = new RegExp('[?&]' + name + '(=([^&#]*)|&|#|$)'), results = regex.exec(url); | |
if (!results) return null; | |
if (!results[2]) return ''; | |
return decodeURIComponent(results[2].replace(/\+/g, ' ')); | |
} | |
/*** LOGGING AND DEBUGGING *************************************************/ | |
function log(message) { | |
return DEBUGGIN && console.log(LOG_PREFIX + message); | |
} | |
})(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment