Архив за Июль 2010

  • От простого к сложному

    Насколько сложной может быть форма из двух полей, отправляющая данные аяксом на сервер? С современными фреймворками вроде jQuery, код может выглядеть примерно так:

    $.post('my-url.php', $('form#my-form').serialize());
    

    Но это минимальный код; код из серии «создаём свой блог/цмс за 5 минут», которым так любят заманивать создатели всяких движков и фреймворков. А что нужно сделать, чтобы такая форма стала удобной и понятной для конечного пользователя, то есть «человеческой»?

    Недавно мы запустили новый скидочный проект BigBuzzy. Так как на рынке уже существует масса подобных проектов, единственный способ выделиться из толпы — это сделать проект очень удобным для пользователя, как с концептуальной точки зрения (не надо ждать, пока наберётся определённая группа; удобные способы оплаты), так и с технологической.

    Одна из фич проекта — отправка купленного купона себе на телефон по СМС. Так как на месте проведения акции нужно предъявить только номер купона, нет особого смысла печатать его на бумаге, можно показать и с телефона. Увидеть форму в действии можно на бигбаззи, купив 10 купонов на этой странице. В этой форме как раз два поля: код и номер телефона. Посмотрим, что можно в ней улучшить.

    Алгоритм работы

    Прежде, чем приступать к работе, нам нужно определиться, какие функции несёт в себе эта форма. Когда пользователь оплатил купон, ему предоставляется на выбор два действия: распечатать или отправить по СМС. Чтобы отправить по СМС, нужно знать номер телефона пользователя. В принципе, можно заставить его ввести этот номер при регистрации, однако это с большой долей вероятности отпугнёт потенциальных покупателей: с чего это вдруг сайт требует с меня номер телефона? Поэтому форму будем показывать только тогда, когда пользователь нажмёт на кнопку «Отправить по СМС»: в этом случае ему будет понятно, почему требуется ввести номер телефона.

    А что делать, если пользователь уже ввёл номер при отправке первого купона и хочет отправить второй? Логично, что он захочет отправить купон на тот же самый номер. Нужно ли в этом случае показывать форму? Решаем, что нужно. Во-первых, пользователь может захотеть отправить купон своему другу в качестве подарка или этот друг только что позвонил и попросил выручить, докупив ещё два купона на боулинг. Заставлять пользователя идти в Личный кабинет и менять там номер телефона как-то уж совсем неправильно. Во-вторых, появляющаяся форма будет своеобразным подтверждением действия (вдруг он случайно нажал на эту кнопку?). Поэтому на нажатие конпки будем запускать анимацию появления формы, а повторное нажатие на эту же кнопку отправить данные на сервер:

    $(document.body).delegate('.coupon-button', 'click', function(/* Event */ evt) {
    	var elem = $(this);
    	if (elem.hasClass('coupon-button-sms')) {
    		var coupon = elem.closest('.coupon');
    		if (!coupon.length)
    			return;
    
    		if (!coupon.hasClass('coupon-sms-mode')) {
    			// переключаем купон в режим отправки СМС
    			switchSMSMode(coupon);
    		} else  {
    			// пытаемся отправить данные на сервер
    			tryToSendSMS(coupon);
    		}
    	}
    });
    

    Функцию анимации появления формы с вводом номера телефона я тут не привожу, так как она довольно большая и скучная (желающие могут посмотреть исходник). Первая проблема, которая может возникнуть на этом шаге: пользователь может случайно дважды кликнуть на кнопку, что приведёт к моментальной отправке СМС, если там уже был вбит номер. Поэтому нужно поставить блокировку на выполнение действия. Это стандартный подход в анимированных интерфейсах, который позволяет избежать случайных нажатий или даже поломки всего интерфейса (может наложиться несколько анимаций друг на друга). Введём переменную is_animating, которая будет равна true в момент анимации. Обнулять эту переменную будем после завершения анимации через callback-функцию для switchSMSMode():

    $(document.body).delegate('.coupon-button', 'click', function(/* Event */ evt) {
    	var elem = $(this),
    		is_animating = false;
    
    	if (elem.hasClass('coupon-button-sms')) {
    		var coupon = elem.closest('.coupon');
    		if (!coupon.length || is_animating)
    			return;
    
    		if (!coupon.hasClass('coupon-sms-mode')) {
    			// переключаем купон в режим отправки СМС
    			is_animating = true;
    			switchSMSMode(coupon, function(){
    				is_animating = false;
    			});
    		} else  {
    			// пытаемся отправить данные на сервер
    			tryToSendSMS(coupon);
    		}
    	}
    });
    

    Ввод данных

    Форму мы показали, теперь нужно упростить пользователю ввод данных. Тут надо помнить, что существует множество моделей поведения пользователя. Кто-то быстро введёт код и переключится табом на следующее поле, кто-то будет минуту набирать три цифры и схватиться за мышку, чтобы переключиться на следующее поле, кто-то будет ожидать, что введённая последовательность из 10 цифр автоматически распределиться между полями. Нужно постараться удовлетворить максимальное количество пользователей. Поэтому будем следить за полем «код», и когда там будет 3 цифры — автоматически перебросим фокус на поле с номером:

    $('.phone-code').keyup(function(){
    	if (this.value.length == 3)
    		$(this).next('.phone-num').focus();
    });
    

    Так делают многие и так делать ни в коем случае нельзя. Если пользователь допустил ошибку в коде, то попытка исправить её используя только клавиатуру превратиться в сущий ад: например, нажатие на стрелки клавиатуры всегда будет приводить к перебросу фокуса, потому что сработает событие событие keyup, а в поле уже будет 3 символа. Чтобы сделать поведение формы более естественным, нужно следить за вводом данных: перебрасывать фокус будем только если в предыдущем нажатии на кнопку (last_length) было меньше символов, чем в этом нажатии, количество символов равно трём и каретка находится в самом конце поля:

    $('.phone-code').bind('keyup change', function(/* Event */ evt) {
    	var field = $(evt.target),
    		last_length = field.data('last_length') || 0,
    		cur_length = field.val().length,
    		max_length = parseInt(field.attr('maxlength')) || 3,
    		selection = getSelectionRange(field[0]);
    
    	if (cur_length > last_length && cur_length == max_length && selection && selection.start == cur_length) {
    		field.next('.phone-number').focus();
    	}
    
    	field.data('last_length', cur_length);
    });
    
    function getSelectionRange(elem) {
    	if ('selectionStart' in elem) { // W3C's DOM
    		return {
    			start: elem.selectionStart,
    			end: elem.selectionEnd
    		};
    	} else if (document.selection) { // IE
    		elem.focus();
    
    		var range = document.selection.createRange(),
    			content = elem.value;
    
    		if (range === null) {
    			return {
    				start: 0,
    				end: content.length
    			};
    		}
    
    		var re = elem.createTextRange();
    		var rc = re.duplicate();
    		re.moveToBookmark(range.getBookmark());
    		rc.setEndPoint('EndToStart', re);
    
    		return {
    			start: rc.text.length,
    			end: rc.text.length + range.text.length
    		};
    	} else {
    		return null;
    	}
    }
    

    Но тут возникает ещё одна проблема. Далеко не каждый пользователь догадается, что при заполнении поля с кодом фокус перекинется на следующее поле. Он может не глядя на монитор набрать 3 цифры, нажать на таб и набрать ещё 7 цифр. В этом случае наша «помощь» только помешает пользователю, потому что фокус будет где-то совершенно в другом месте. Чтобы такого не случилось, нужно временно заблокировать нажатие на таб на поле с номером телефона после того, как фокус был автоматически переброшен:

    $('.phone-code').bind('keyup change', function(/* Event */ evt) {
    	var field = $(evt.target),
    		last_length = field.data('last_length') || 0,
    		cur_length = field.val().length,
    		max_length = parseInt(field.attr('maxlength')) || 3,
    		selection = getSelectionRange(field[0]);
    
    	if (cur_length > last_length && cur_length == max_length && selection && selection.start == cur_length) {
    		var num_field = field.next('.phone-number');
    
    		// временно блокируем Tab на следующем поле
    		num_field.data('tab_locked', true);
    		setTimeout(function() {
    			num_field.data('tab_locked', false);
    		}, 1000);
    
    		num_field.focus();
    	}
    
    	field.data('last_length', cur_length);
    });
    
    $('.phone-number').bind('keydown keyup keypress', function(/* Event */ evt) {
    	var field = $(evt.target);
    	if (field.data('tab_locked') === true && evt.keyCode == 9) {
    		// блокируем работу клавиши Tab
    		evt.preventDefault();
    	}
    });
    

    Валидация данных

    Перед отправкой данных на сервер нужно сделать простую валидацию данных, чтобы убедиться, что введённые данные похожи на номер телефона. Если нет — то намекнём об этом пользователю.

    Вводить жёсткий формат записи числовых данных вроде номера телефона или кредитной карты — первый признак полового бессилия разработчика. Для номера телефона нам нужно 10 цифр (код + сам номер), и не важно, написаны цифры слитно или разделены пробелами или дефисами. Пользователь должен написать номер в привычном ему формате, чтобы легче было проверять правильность. Поэтому при валидации удалим из полей все не числовые символы и проверим длину того, что осталось:

    function validatePhoneNumber(coupon) {
    	coupon = $(coupon);
    
    	var re_num = /[^0-9]/g,
    		phone_code = coupon.find('.phone-code'),
    		phone_num = coupon.find('.phone-number'),
    		is_valid_code = phone_code.val().replace(re_num, '').length == 3,
    		is_valid_num = phone_num.val().replace(re_num, '').length == 7;
    
    	if (!is_valid_code)
    		blinkField(phone_code);
    	if (!is_valid_num)
    		blinkField(phone_num);
    
    	return is_valid_code && is_valid_num;
    }
    

    Если введённые данные не верны, то мы должны сообщить об этом пользователю. Пространство у нас очень ограничено, поэтому будем «моргать» полем, указывая, что там что-то не так. Функция добавляет и удаляет определённый класс у элемента через заданный промежуток времени, в CSS укажем этому классу красный цвет:

    function blinkField(fld) {
    	fld = $(fld);
    	if (fld.data('blink_timer'))
    		clearInterval(fld.data('blink_timer'));
    
    	var blink_count = 6;
    
    	fld.data('blink_timer', setInterval(function() {
    		fld.toggleClass('coupon-field-warning');
    		if (blink_count-- < 0) {
    			fld.removeClass('coupon-field-warning');
    			clearInterval(fld.data('blink_timer'));
    		}
    	}, 100));
    }
    

    Отправка данных

    Данные от пользователя получили и проверили, настало время отправить их на сервер. Опять же, тут есть две потенциальные проблемы. Первая — это слишком медленный интернет. Нужно показать пользователю, что процесс запущен и скоро что-то произойдёт. Вторая проблема не так очевидна — это слишком быстрый интернет. Настолько быстрый, что пользователь может и не понять, а произошло ли что-то, отправились ли данные или возникла ошибка? Чтобы избавиться от этих проблем, нужно показать показать некий индикатор процесса, причём этот индикатор должен искусственно замедлить получение фидбэка от сервера. Он должен отображаться на странице некоторое время, чтобы пользователь успел понять, что запущен некий процесс.

    Кто-то решает эту задачу так: показывает индикатор, ждёт определённое время и уже после этого отправляет запрос на сервер, а после получения ответа прячет индикатор. Так делать не правильно: в случае с медленным интернетом таймаут слишком сильно увеличивается; сам запрос мог бы запросто отработать за то время, пока показывается индикатор. Поэтому мы устроим небольшую гонку: что последним закончится выполняться — таймер индикатора или запрос на сервер — то и покажет фидбэк пользователю:

    function sendSMSRequest(coupon) {
    	coupon = $(coupon);
    	var phone_code = coupon.find('.phone-code').val(),
    		phone_num = coupon.find('.phone-number').val();
    
    	coupon.addClass('coupon-preloader');
    	// запускаем индикатор
    	preloader.start();
    
    	// два условия отображения фидбэка: закончился таймер и пришёл ответ от сервера
    	var timer_reached = false,
    		response_received = false;
    
    	function checkStatus() {
    		if (timer_reached && response_received) {
    			// останавливаем индикатор
    			preloader.start();
    			coupon.removeClass('coupon-preloader');
    
    			// прячем форму с номером телефона
    			switchSMSMode(coupon);
    		}
    	}
    
    	// устраиваем гонку между таймером и запросом
    	setTimeout(function() {
    		timer_reached = true;
    		checkStatus();
    	}, 1000);
    
    	$.get(sms_url, {
    		id: coupon.attr('id'),
    		action: 'send_sms',
    		phone: phone_code + phone_num
    	}, function(data) {
    		response_received = true;
    		checkStatus();
    	}, 'json');
    }
    

    Вот так, двигаясь от простого к сложному, всего за несколько часов работы мы улучшили форму из целых двух полей, которую не стыдно показывать людям. Можно, конечно, улучшить ещё немного, явно сообщая пользователю о статусе отправки сообщения, но это уже другая история. Для сравнения: что было и что стало.

    code-example