Created
September 29, 2015 23:11
-
-
Save alanhamlett/55e08ec7a599944ae35b to your computer and use it in GitHub Desktop.
Utility Function to Map JSON errors from wtforms-json onto an HTML form
This file contains 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() { | |
var utils = {}; | |
utils.clear_form_errors = function($el) { | |
$el.find('.text-danger').each(function() { | |
$(this).empty(); | |
}); | |
return $el; | |
}; | |
utils.set_form_errors = function($el, json) { | |
function flash_errors(obj, namespace) { | |
if (!namespace) | |
namespace = ''; | |
if (_.isArray(obj)) { | |
if (_.isObject(obj[0])) | |
obj[0] = _.values(obj[0])[0]; | |
var add_namespace = false; | |
var $div = $el.find('.flash-'+namespace+',#flash-'+namespace); | |
if (!$div.length) { | |
var dotns = namespace.replace('-', '.'); | |
if (namespace) { | |
var $inp = $el.find('input[name="'+dotns+'"],select[name="'+dotns+'"],textarea[name="'+dotns+'"],#'+dotns); | |
if ($inp.length) { | |
$div = $('<div class="text-danger" />'); | |
if ($inp.parent().hasClass('input-group')) { | |
$inp.parent().before($div); | |
} else { | |
$inp.parent().append($div); | |
} | |
} | |
} | |
if (!$div.length) { | |
$div = $el.find('.flash-error,#flash-error'); | |
if (!$div.length) { | |
$div = $('<div class="text-danger" />'); | |
$el.prepend($div); | |
add_namespace = !!namespace; | |
} | |
} | |
} | |
if (add_namespace) | |
$div.text(namespace+': '+obj[0]); | |
else | |
$div.text(obj[0]); | |
if (obj[0] === 'User account can not view time before one week ago.') { | |
$div.append('<a href="/change_plan/premium" class="btn btn-primary m-left-xs-10">Upgrade to Premium</a> <span style="color:#000;">or</span> <a href="/referrals" class="btn btn-primary">Refer a Friend</a>'); | |
window.location = '/referrals'; | |
} | |
} else if(_.isObject(obj)) { | |
for (var name in obj) { | |
var newnamespace = namespace ? namespace+'-'+name : name; | |
flash_errors(obj[name], newnamespace); | |
} | |
} else { | |
var add_namespace = false; | |
var $div = $el.find('.flash-'+namespace+',#flash-'+namespace); | |
if (!$div.length) { | |
var dotns = namespace.replace('-', '.'); | |
if (namespace) { | |
var $inp = $el.find('input[name="'+dotns+'"],select[name="'+dotns+'"],textarea[name="'+dotns+'"],#'+dotns); | |
if ($inp.length) { | |
$div = $('<div class="text-danger" />'); | |
if ($inp.parent().hasClass('input-group')) { | |
$inp.parent().before($div); | |
} else { | |
$inp.parent().append($div); | |
} | |
} | |
} | |
if (!$div.length) { | |
$div = $el.find('.flash-error,#flash-error'); | |
if (!$div.length) { | |
$div = $('<div class="text-danger" />'); | |
$el.prepend($div); | |
add_namespace = !!namespace; | |
} | |
} | |
} | |
if (add_namespace) | |
$div.text(namespace+': '+obj); | |
else | |
$div.text(obj); | |
if (obj === 'User account can not view time before one week ago.') { | |
$div.append('<a href="/change_plan/premium" class="btn btn-primary m-left-xs-10">Upgrade to Premium</a> <span style="color:#000;">or</span> <a href="/referrals" class="btn btn-primary">Refer a Friend</a>'); | |
window.location = '/referrals'; | |
} | |
} | |
} | |
utils.clear_form_errors($el); | |
if (!_.isObject(json)) | |
json = utils.parse_errors(json); | |
if (json.error) { | |
if (_.isObject(json.error)) | |
json.error = json.error.message || 'An error occurred'; | |
var $div = $el.find('.flash-error,#flash-error'); | |
if (!$div.length) { | |
$div = $('<div class="text-danger" />'); | |
$el.prepend($div); | |
} | |
$div.text(json.error); | |
} else if (json.errors) { | |
flash_errors(json.errors); | |
} else { | |
flash_errors(json); | |
} | |
}; | |
utils.parse_errors = function(text) { | |
var json = false; | |
try { | |
json = JSON.parse(text); | |
} catch (e) { } | |
if (!json) | |
json = {}; | |
if (!json.error && !json.errors) | |
json['error'] = 'Oops, something went wrong.'; | |
if (_.isObject(json.errors) && !_.isArray(json.errors)) { | |
for (var key in json.errors) { | |
if (_.isArray(json.errors[key])) | |
json.errors[key] = _.first(json.errors[key]); | |
} | |
} | |
return json; | |
}; | |
var explode = function(key, value, obj) { | |
if (!_.isObject(obj)) obj = {}; | |
var key = key.split('.'); | |
if (key.length > 1) { | |
var firstKey = key.shift() | |
obj[firstKey] = explode(key.join('.'), value, obj[firstKey]); | |
} else { | |
obj[key[0]] = value; | |
} | |
return obj; | |
}; | |
var extract = function(obj, key) { | |
if (!_.isObject(obj)) obj = {}; | |
var key = key.split('.'); | |
if (key.length > 1) { | |
var firstKey = key.shift() | |
return extract(obj[firstKey], key.join('.')); | |
} | |
return obj[key[0]]; | |
}; | |
utils.get_form_inputs = function($el, expand) { | |
if (_.isUndefined(expand)) expand = true; | |
var data = {}; | |
$el.find('input,select,textarea').each(function() { | |
var $el = $(this); | |
var value = undefined; | |
if ($el.attr('type') === 'checkbox') { | |
value = $el.is(':checked'); | |
} else if ($el.attr('type') === 'radio') { | |
if ($el.is(':checked')) value = $el.val(); | |
} else { | |
value = $el.val(); | |
} | |
var name = $el.attr('name'); | |
var type = $el.attr('data-type') || 'string'; | |
if (type == 'integer' || type == 'int') var value = $el.find('input').val(); | |
if (type == 'float') value = parseFloat(value); | |
if (type == 'bool' || type == 'boolean') value = !!value; | |
if (expand) { | |
data = explode(name, value, data); | |
} else { | |
data[name] = value; | |
} | |
}); | |
return data; | |
}; | |
utils.confirmBeforeSubmit = function(e) { | |
var msg = $(this).attr('data-confirm') || 'Are you sure?'; | |
if (!window.confirm(msg)) { | |
e.preventDefault(); | |
e.stopPropagation(); | |
} | |
}; | |
$('form.confirm').each(function() { | |
$(this).on('submit', $.proxy(utils.confirmBeforeSubmit, this)); | |
$(this).removeClass('confirm'); | |
}); | |
jQuery.fn.highlight = function(color) { | |
if (_.isUndefined(color)) color = '#53C774'; | |
$(this).each(function() { | |
var el = $(this); | |
el.before("<div/>") | |
el.prev() | |
.width(el.width()) | |
.height(el.height()) | |
.css({ | |
'position': 'absolute', | |
'background-color': color, | |
'opacity': '.9', | |
'margin': el.css('margin'), | |
'padding': el.css('padding'), | |
}) | |
.fadeOut(900); | |
}); | |
} | |
utils.browserSpecificRender = function() { | |
var mozilla = !!navigator.userAgent.match(/firefox/i); | |
if (mozilla) { | |
$('.hide-if-mozilla').hide(); | |
$('.show-unless-mozilla').hide(); | |
$('.hide-unless-mozilla').removeClass('hide-unless-mozilla'); | |
$('.show-if-mozilla').removeClass('show-if-mozilla'); | |
} | |
}; | |
utils.browserSpecificRender(); | |
utils.clickToShow = function() { | |
$('.click-to-show').parent().click(function(e) { | |
var $el = $(e.target); | |
if ($el.hasClass('click-to-show')) { | |
var name = $el[0].nodeName.toLowerCase(); | |
var value = $el.attr('data-click-value'); | |
if (name == 'span') | |
$el.text(value); | |
else | |
$el.val(value); | |
$el.removeClass('click-to-show'); | |
} | |
}); | |
}; | |
utils.clickToShow(); | |
utils.clickToCopy = function(selector) { | |
var $el = $(selector); | |
var clipboard = new Clipboard(selector); | |
clipboard.on('success', function(e) { | |
$el.attr('data-original-title', 'Copied!'); | |
$el.tooltip({ | |
trigger: 'manual', | |
}); | |
$el.tooltip('show'); | |
e.clearSelection(); | |
}); | |
clipboard.on('error', function(e) { | |
$el.attr('data-original-title', 'Press Ctrl+C to copy'); | |
$el.tooltip({ | |
trigger: 'manual', | |
}); | |
$el.tooltip('show'); | |
}); | |
return clipboard; | |
}; | |
function saveEditable($el, method) { | |
if (!method) method = 'PATCH'; | |
$.ajax({ | |
type: method, | |
url: $el.attr('data-url')+'?show='+encodeURIComponent($el.find('input').attr('name')), | |
contentType: 'application/json', | |
data: JSON.stringify(utils.get_form_inputs($el)), | |
}).fail(function(response) { | |
utils.set_form_errors($el, response.responseText); | |
}).done(function(data) { | |
$el.find('span').text(extract(data.data, $el.find('input').attr('name'))); | |
$el.editing = false; | |
}); | |
} | |
$('.editable').each(function() { | |
var $el = $(this); | |
var value = $el.text(); | |
var $edit = $('<a href="#"><i class="fa fa-edit"></i></a>'); | |
$el.html('<span></span>').append($edit).find('span').text(value); | |
var space = 5; | |
$edit.css({ | |
'padding-left': space+'px', | |
'display': 'inline-block', | |
'font-size': '18px', | |
'width': '18px', | |
}); | |
$el.css('padding-right', ($edit.width()+space)+'px'); | |
$edit.css('display', 'none'); | |
$el.hover(function() { | |
if (!$el.editing) { | |
$el.css('padding-right', '0px'); | |
$edit.css('display', 'inline-block'); | |
} | |
}, function() { | |
$el.css('padding-right', ($edit.width()+space)+'px'); | |
$edit.css('display', 'none'); | |
}); | |
$edit.on('click', function(e) { | |
e && e.preventDefault(); | |
$el.editing = true; | |
$el.css('padding-right', $edit.width()+'px'); | |
$edit.css('display', 'none'); | |
$el.attr('data-value', $el.find('span').text()); | |
var $inp = $('<input type="text" />'); | |
if ($el.attr('data-name')) $inp.attr('name', $el.attr('data-name')); | |
if ($el.attr('data-type')) $inp.attr('data-type', $el.attr('data-type')); | |
$el.find('span').html($inp); | |
$inp.focus(); | |
$inp.val($el.attr('data-value')); | |
$inp.blur(function() { | |
saveEditable($el); | |
}); | |
$inp.keyup(function(e) { | |
if ((e.keyCode ? e.keyCode : e.which) == 13) | |
$inp.blur(); | |
}); | |
}); | |
}); | |
utils.formatSecondsForDisplay = function(num, without_commas) { | |
var units = 'Seconds'; | |
if (!num) { | |
return { | |
value: 0, | |
units: units, | |
}; | |
} | |
if (num > 60) { | |
units = 'Minutes'; | |
num = Math.floor(num / 60.0); | |
} | |
if (num > 60) { | |
units = 'Hours'; | |
num = Math.floor(num / 60.0); | |
} | |
if (!without_commas) num = utils.numberWithCommas(num); | |
return { | |
value: num, | |
units: units, | |
}; | |
}; | |
utils.numberWithCommas = function(x) { | |
return x.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ","); | |
}; | |
utils.format_seconds = function(total_seconds, show_seconds, hide_minutes) { | |
var text = ''; | |
if (total_seconds == 0 || (!show_seconds && total_seconds < 60)) { | |
return '0 minutes'; | |
} | |
var hours = Math.floor(total_seconds / 3600.0); | |
var minutes = Math.floor(total_seconds / 60.0) % 60; | |
var seconds = total_seconds % 60; | |
if (hours > 0) { | |
text += hours + ' hour'; | |
if (hours !== 1) { | |
text += 's'; | |
} | |
} | |
if (minutes > 0 && !hide_minutes) { | |
text += ' ' + minutes + ' minute'; | |
if (minutes !== 1) { | |
text += 's'; | |
} | |
} | |
if (seconds > 0 && show_seconds) { | |
text += ' ' + seconds + ' second'; | |
if (seconds !== 1) { | |
text += 's'; | |
} | |
} | |
return $.trim(text); | |
}; | |
utils.commonPath = function(paths, sep) { | |
if (paths.length < 2) | |
return ''; | |
if (!sep) | |
sep = '/'; | |
var hash = {}; | |
_.each(paths, function(path) { | |
if (path !== undefined) { | |
if (path.substring(0, 1) == sep) | |
path = path.substring(1); | |
var tok = path.split(sep); | |
_.each(tok, function(t, index) { | |
if (!(tok.slice(0,index).join(sep) in hash)) | |
hash[tok.slice(0,index).join(sep)] = 0; | |
hash[tok.slice(0,index).join(sep)] += 1; | |
}); | |
} | |
}); | |
var max = _.max(_.values(hash), function(v) { return v;}); | |
if (max != paths.length) | |
return ''; | |
var res = _.filter(_.keys(hash), function(h) { | |
return hash[h] == max; | |
}); | |
var common = _.reduce(res, function(a, b) { | |
return a.length > b.length ? a : b; | |
}); | |
if (common.length > 0) | |
common = sep+common; | |
return common; | |
}; | |
utils.toMoment = function(dateString, timezone) { | |
if (_.isString(dateString)) { | |
dateString = dateString.replace('/', '-').replace('/', '-'); | |
if (timezone) { | |
if (dateString.match(/\d\d\d\d-\d\d-\d\d/)) return moment.tz(dateString, 'YYYY-MM-DD', timezone); | |
if (dateString.match(/\d\d-\d\d-\d\d\d\d/)) return moment.tz(dateString, 'MM-DD-YYYY', timezone); | |
} else { | |
if (dateString.match(/\d\d\d\d-\d\d-\d\d/)) return moment(dateString, 'YYYY-MM-DD'); | |
if (dateString.match(/\d\d-\d\d-\d\d\d\d/)) return moment(dateString, 'MM-DD-YYYY'); | |
} | |
} else if (_.isNumber(dateString)) { | |
dateString = moment.unix(dateString); | |
if (timezone) dateString = dateString.tz(timezone); | |
return dateString; | |
} else if (moment.isMoment(dateString)) { | |
return dateString.clone(); | |
} | |
return dateString; | |
}; | |
utils.naturalDate = function(date, timezone) { | |
date = utils.toMoment(date, timezone); | |
var today = moment(); | |
if (timezone) today = today.tz(timezone); | |
if (today.isSame(date, 'day')) { | |
return 'Today'; | |
} | |
if (today.clone().subtract(1, 'days').isSame(date, 'day')) { | |
return 'Yesterday'; | |
} | |
return date.format('ddd MMM Do'); | |
}; | |
utils.naturalDateRange = function(start, end, timezone) { | |
start = utils.toMoment(start, timezone); | |
end = utils.toMoment(end, timezone); | |
var today = moment(); | |
if (timezone) today = today.tz(timezone); | |
var range = Math.abs(end.diff(start.clone().subtract(1, 'days'), 'days')); | |
var range_as_text = end.from(start.clone().subtract(1, 'days'), true); | |
if (start.isSame(end, 'day')) { | |
return {text:utils.naturalDate(start), range: range, range_as_text: range_as_text}; | |
} | |
if (today.isSame(end, 'day') && today.clone().subtract(6, 'days').isSame(start, 'day')) { | |
return {text:'Last 7 Days', article:'in the', range: range, range_as_text: range_as_text}; | |
} | |
if (today.clone().subtract(1, 'days').isSame(end, 'day') && today.clone().subtract(7, 'days').isSame(start, 'day')) { | |
return {text:'Last 7 Days from Yesterday', article:'in the', range: range, range_as_text: range_as_text}; | |
} | |
if (today.isSame(end, 'day') && today.clone().subtract(29, 'days').isSame(start, 'day')) { | |
return {text:'Last 30 Days', article:'in the', range: range, range_as_text: range_as_text}; | |
} | |
if (today.clone().subtract(1, 'days').isSame(end, 'day') && today.clone().subtract(30, 'days').isSame(start, 'day')) { | |
return {text:'Last 30 Days from Yesterday', article:'in the', range: range, range_as_text: range_as_text}; | |
} | |
if (today.startOf('week').isSame(start, 'day') && today.clone().endOf('week').isSame(end, 'day')) { | |
return {text:'This Week', range: range, range_as_text: range_as_text}; | |
} | |
if (today.clone().subtract(1, 'week').startOf('week').isSame(start, 'day') && today.clone().subtract(1, 'week').endOf('week').isSame(end, 'day')) { | |
return {text:'Last Week', range: range, range_as_text: range_as_text}; | |
} | |
if (today.startOf('month').isSame(start, 'day') && today.isSame(end, 'day')) { | |
return {text:'This Month', range: range, range_as_text: range_as_text}; | |
} | |
if (today.clone().subtract(1, 'month').startOf('month').isSame(start, 'day') && today.clone().subtract(1, 'month').endOf('month').isSame(end, 'day')) { | |
return {text:'Last Month', range: range, range_as_text: range_as_text}; | |
} | |
return {text: utils.naturalDate(start) + ' until ' + utils.naturalDate(end), article:'from', range: range, range_as_text: range_as_text}; | |
}; | |
utils.hideModalWhenEscapePressed = function() { | |
$('.modal').modal('show').on('hidden.bs.modal', function (e) { | |
$(document).unbind('keyup'); | |
}); | |
$(document).keyup(function(e) { | |
if (e.keyCode == 27) $('.modal').modal('hide'); | |
}); | |
}; | |
utils.initProgressBar = function(startValue) { | |
var progress = progressJs(); | |
progress.setOptions({ | |
overlayMode: false, | |
theme: 'blueOverlay', | |
}); | |
progress.start(); | |
if (startValue) progress.set(startValue); | |
progress.autoIncrease(4, 100); | |
return progress; | |
}; | |
utils.updateTimeZone = function(timezone, options) { | |
if (timezone) { | |
var params = { | |
type: 'PATCH', | |
url: '/api/v1/users/current', | |
contentType: 'application/json', | |
data: JSON.stringify({ | |
timezone: timezone, | |
}), | |
}; | |
params = _.defaults(options || {}, params); | |
$.ajax(params); | |
} | |
}; | |
utils.getTimeRanges = function(timezone) { | |
if (moment !== undefined && timezone) { | |
return { | |
'Today': [moment().tz(timezone), moment().tz(timezone)], | |
'Yesterday': [moment().tz(timezone).subtract(1, 'days'), moment().tz(timezone).subtract(1, 'days')], | |
'Last 7 Days': [moment().tz(timezone).subtract(6, 'days'), moment().tz(timezone)], | |
'Last 30 Days': [moment().tz(timezone).subtract(29, 'days'), moment().tz(timezone)], | |
'This Week': [moment().tz(timezone).startOf('week'), moment().tz(timezone).endOf('week')], | |
'Last Week': [moment().tz(timezone).subtract(1, 'week').startOf('week'), moment().tz(timezone).subtract(1, 'week').endOf('week')], | |
'This Month': [moment().tz(timezone).startOf('month'), moment().tz(timezone)], | |
'Last Month': [moment().tz(timezone).subtract(1, 'month').startOf('month'), moment().tz(timezone).subtract(1, 'month').endOf('month')], | |
}; | |
} else { | |
return [ | |
'Today', | |
'Yesterday', | |
'Last 7 Days', | |
'Last 30 Days', | |
'This Week', | |
'Last Week', | |
'This Month', | |
'Last Month', | |
]; | |
} | |
}; | |
utils.getUrlParams = function() { | |
var params = {}; | |
_.each(window.location.search.replace('?', '').split('&'), function(param) { | |
param = param.split('=', 2); | |
if (param.length == 2) { | |
params[decodeURIComponent(param[0])] = decodeURIComponent(param[1]); | |
} | |
}); | |
return params; | |
}; | |
utils.updateUrl = function(params, callback) { | |
// Overwrite existing url args with new params. | |
params = _.defaults(params || {}, utils.getUrlParams()); | |
// Turn params into browser query string. | |
var search = '?' + _.map(params, function(val, key) { return [encodeURIComponent(key), encodeURIComponent(val)].join('='); }).join('&'); | |
// Check if the browser supports HTML5 push state. | |
if (window.history) { | |
var url = window.location.pathname + search; | |
window.history.pushState({}, document.title, url); | |
if (callback) callback(); | |
} else { | |
// If browser does not support push state, we update the url | |
// with new params causing the browser to make a new request. | |
window.location.search = search; | |
} | |
}; | |
utils.niceScroll = function(padding) { | |
if (!padding) padding = 0; | |
$('.scroll').each(function() { | |
var $el = $(this); | |
var url = $el.attr('href'); | |
$el.click(function(e) { | |
var $anchor = $('a[name="'+url.substring(1)+'"]'); | |
if (url.indexOf('#') == 0 && $anchor.length) { | |
e && e.preventDefault(); | |
$('html,body').animate({ | |
scrollTop: $anchor.next().offset().top + $anchor.next().scrollTop() - padding, | |
}, 1000); | |
} | |
}); | |
}); | |
}; | |
utils.niceScroll(); | |
utils.shortenLoggedTimeText = function(text) { | |
if (!_.isString(text)) return text; | |
return text.replace('hours', 'h').replace('hour', 'h').replace('minutes', 'm').replace('minute', 'm'); | |
}; | |
utils.renderTips = function() { | |
$('.tip').tooltip(); | |
$('[data-toggle="popover"]').popover(); | |
$('[data-toggle="popover"]').on('shown.bs.popover', function(e) { | |
var $content = $('#'+$(e.target).attr('aria-describedby')); | |
$content.length && $content.attr('data-forceclose', 'false'); | |
}); | |
$('[data-toggle="popover"]').on('hide.bs.popover', function(e) { | |
var $el = $(e.target); | |
if (s.include($el.attr('data-trigger'), 'hover')) { | |
var $content = $('#'+$(e.target).attr('aria-describedby')); | |
if ($content.length && $content.attr('data-forceclose') != 'true') { | |
e && e.preventDefault(); | |
$content.on('mouseleave', function(e2) { | |
$content.attr('data-forceclose', 'true'); | |
$el.popover('hide'); | |
}); | |
var timeout = setTimeout(function() { | |
$content.attr('data-forceclose', 'true'); | |
$el.popover('hide'); | |
}, 500); | |
$content.on('mouseenter', function(e2) { | |
if (timeout) clearTimeout(timeout); | |
}); | |
} | |
} | |
}); | |
}; | |
utils.hideSumo = function() { | |
var hide = function() { | |
if ($('a[title="SumoMe"]').length) { | |
$('a[title="SumoMe"]').attr('data-style', $('a[title="SumoMe"]').attr('style')); | |
$('a[title="SumoMe"]').attr('style', 'display:none;'); | |
} else { | |
setTimeout(hide, 20); | |
} | |
}; | |
hide(); | |
}; | |
utils.showSumo = function() { | |
if ($('a[title="SumoMe"]').length) { | |
$('a[title="SumoMe"]').attr('style', $('a[title="SumoMe"]').attr('data-style')); | |
} | |
}; | |
// mark notifications as read after viewed | |
$('#announcements-icon').on('click', function(e) { | |
if ($('#announcements-icon #announcements-icon-dropdown i.fa-circle').hasClass('has-announcements')) { | |
$.ajax({ | |
type: 'POST', | |
url: '/api/v1/users/current/announcements/dismiss', | |
}).fail(function(response) { | |
$('#announcements-icon #announcements-icon-dropdown i.fa-circle').removeClass('has-announcements'); | |
$('#announcements-icon #announcements-icon-dropdown i.fa span').remove(); | |
$('#announcements-icon #announcements-icon-dropdown i.fa-bell').removeClass('hidden'); | |
}).done(function(modal_html) { | |
$('#announcements-icon #announcements-icon-dropdown i.fa-circle').removeClass('has-announcements'); | |
$('#announcements-icon #announcements-icon-dropdown i.fa span').remove(); | |
$('#announcements-icon #announcements-icon-dropdown i.fa-bell').removeClass('hidden'); | |
}); | |
} | |
}); | |
// prevent clicking on notifications from closing the dropdown | |
$('#announcements-icon ul.dropdown-menu').on('click', function(e) { | |
e && e.stopPropagation(); | |
}); | |
window.utils = utils; | |
})(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment