Skip to content

Instantly share code, notes, and snippets.

@smartcatdev
Last active August 31, 2016 03:13
Show Gist options
  • Save smartcatdev/b8589e38c2c2ececce171ddadc2217be to your computer and use it in GitHub Desktop.
Save smartcatdev/b8589e38c2c2ececce171ddadc2217be to your computer and use it in GitHub Desktop.
(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