FormLister: форма обратной связи

+ антиспам, ajax, Bootstrap

Обычная форма обратной связи, которую я использую везде.

12.09.2019

В каждом-каждом проекте обязательно есть форма обратной связи. И вот этот кусочек функционала, который можно копипастить. Пара слов об особенностях именно этой реализации. Во-первых, это FormLister. Во-вторых, здесь есть набор правил для защиты от спама, а также контролируется время на заполнение формы. Идеи не новые: запрещаем имени содержать латинские буквы и цифры; запрещаем в поле для сообщения вставлять все, что похоже на ссылку; не отправляем данные, если форму заполнили быстрее, чем за две секунды. В-третьих, предполагается использование Bootstrap, поэтому все классы заточены под него. В-четвертых, используется ajax для отправки формы из всплывающего окошка.

Вот чанк (или часть шаблона, если вы - редиска), где описано всплывающее окошко, обычное для Bootstrap, и вызов чанка формы.

<!-- Modal -->
<div class="modal fade" id="feedbackModal" tabindex="-1" aria-labelledby="feedbackModalLabel" aria-hidden="true">
	<div class="modal-dialog" role="document">
		<div class="modal-content">
			<div class="modal-header">
				<div class="modal-title" id="feedbackModalLabel">Записаться на консультацию</div>
				<button type="button" class="close" data-dismiss="modal" aria-label="Close">
					<span aria-hidden="true">&times;</span>
				</button>
			</div>
			<div class="modal-body">
				<div id="feedbackDiv">
					{{feedbackTpl}}
				</div>
			</div>
		</div>
	</div>
</div>

А вот кусочек скрипта, который "слушает" submit нашей формы, отправляет данные и получает ответ от FormLister, а также проставляет время инициализации формы в скрытом поле для блокировки спама. Обратите внимание на то, что в этом скрипте можно отправить данные о достижении цели для Яндекс.Метрики.

var ajax = {
	post: function(form_wrapper_id,form_id,query_key,success_callback){
		//обёртка формы, айди формы, ключ запроса, код на успех
		$.ajax({
			type: 'post',
			url: '/ajax.php?q=' + query_key,
			data: $(form_id).serialize(),
			success:success_callback
		});
	}	
};

$(document).ready(function(){
	var now = (Date.now ? Date.now() : new Date().getTime()) / 1000;
	$('input[name="now"]').each(function(){
		$(this).val(now);
	});
	
	$(document).on('submit', '#feedbackForm',function(e){
		ajax.post(
			'#feedbackDiv',
			'#feedbackForm',
			'feedback',
			function(data){
				//console.log(data);
				$('#feedbackDiv').html(data);
				//ym(XXXXXXXX, 'reachGoal', 'goal');
			}
		);
		e.preventDefault();
	});
});

Отправленные данные из формы обрабатываются сниппетом FormLister, который вызывается в файле ajax.php, расположенном в корне сайта. Вот пример этого файла:

<?php
define('MODX_API_MODE', true);
include_once("index.php");
$modx->db->connect();
if (empty ($modx->config)){
    $modx->getSettings();
}
$modx->invokeEvent("OnWebPageInit");

if(!isset($_SERVER['HTTP_X_REQUESTED_WITH']) || (strtolower($_SERVER['HTTP_X_REQUESTED_WITH']) != 'xmlhttprequest')){
    $modx->sendRedirect($modx->config['site_url']);
}

switch($_REQUEST['q']){
	case 'feedback':
		$result = $modx->runSnippet('FormLister', array(
			'formid' => 'feedbackForm',
			'formTpl' => 'feedbackTpl',
			'prepare' => 'checkSpamTimeFL',
			'formControls' => 'greeCheck,sendCheck',
			'emptyFormControls' => '{"sendCheck":"0","agreeCheck":""}',
			'successTpl' => 'feedbackSuccess',
			'errorTpl' => '@CODE: <div class="invalid-feedback">[+message+]</div>',
			'requiredClass' => 'is-invalid',
			'errorClass' => 'is-invalid',
			'to' => $modx->getConfig('email_mngr'),
			'subject' => 'Запись на консультацию',
			'reportTpl' => 'feedbackReport',
			'ccSender' => '1',
			'parseMailerParams' => '1',
			'replyTo' => '[+email.value+]',
			'autoSubject' => 'Запись на консультацию',
			'ccSenderTpl' => 'feedbackCcReport',
			'rules' => '{
				"name":{
					"required":"Пожалуйста, представьтесь.",
					"matches":{
						"params":"/^[^a-zA-Z0-9]+$/u",
						"message":"Пожалуйста, представьтесь на русском языке."
					}
				},
				"phone":{
					"required":"Пожалуйста, укажите свой номер телефона для связи с вами.",
					"phone":"Проверьте, пожалуйста, указанный номер телефона."
				},
				"!email":{
					"email":"Проверьте, пожалуйста, указанный адрес электронной почты."
				},
				"!comment":{
					"matches":{
						"params":"/^(?:(?!\\w+\\.\\w+).)*$/",
						"message":"Здесь нельзя отправлять ссылки. Извините за неудобства."
					}
				},
				"agreeCheck":{
					"required":"Требуется ваше согласие."
				}
			}'				
		));
		echo $modx->parseDocumentSource($result);
		exit();
	break;
	default:
		$modx->sendForward($modx->config['error_page']);
	break;
}
?>

Теперь о параметрах FormLister.

&formid=`feedbackForm` - в самой форме нужно скрытое поле formid с этим же значением, + я ставлю такой же id для самой формы.
&formTpl=`feedbackTpl` - чанк с формой.
&prepare=`checkSpamTimeFL` - вызов сниппета, в котором описана проверка времени для блокировки спама. Подробнее об этом будет ниже.
&formControls=`agreeCheck,sendCheck` - список имен полей-чекбоксов и радиобаттонов, которые должны быть установлены.
&emptyFormControls=`{"sendCheck":"0","agreeCheck":""}` - список имен полей-чекбоксов и радиобаттонов, которые есть в форме, могут быть установлены пользователем, а могут быть проигнорированы. Здесь мы задаем им значения по умолчанию.
&successTpl=`feedbackSuccess` - чанк с сообщением пользователю об успешной отправке данных.
&errorTpl=`@CODE: <div class="invalid-feedback">[+message+]</div>` - код вывода общего сообщения об ошибках.
&requiredClass=`is-invalid` - класс ошибки для полей, которые обязательны к заполнению, но не заполнены.
&errorClass=`is-invalid` - класс ошибки для полей, в которые введены некорректные данные.
&to=`[(email_mngr)]` - здесь адрес администратора сайта. Я разделяю адрес отправителя по умолчанию и адрес администратора сайта. Почту админа храню в конфиге сайта, записываю ее туда с помощью плагина customSettings.
&subject=`Запись на консультацию` - тема письма для админа сайта.
&reportTpl=`feedbackReport` - чанк с текстом письма админу.
&ccSender=`1` - нужно отправлять письмо автоответчика пользователю.
&parseMailerParams=`1` - разрешаем парсить отправленную форму на предмет почты пользователя - туда отправит своё письмо автоответчик.
&replyTo=`[+email.value+]` - нужно отправлять письмо пользователю на его электронку, она живет в плейсхолдере [+email.value+].
&autoSubject=`Запись на консультацию` - тема письма автоответчика. Выделю этот параметр отдельно, поскольку обычно темы отличаются формулировкой.
&ccSenderTpl=`feedbackCcReport` - чанк с текстом письма автоответчика.
&rules - правила проверки данных в полях формы, далее подробнее:

"name":{
	"required":"Пожалуйста, представьтесь.",
	"matches":{
		"params":"/^[^a-zA-Z0-9]+$/u",
		"message":"Пожалуйста, представьтесь на русском языке."
	}
},

Поле для ввода имени обязательно к заполнению, и туда нельзя вписать латинские буквы или цифры.

"phone":{
	"required":"Пожалуйста, укажите свой номер телефона для связи с вами.",
	"phone":"Проверьте, пожалуйста, указанный номер телефона."
},

Поле для ввода номера телефона обязательно к заполнению, и данные обязаны быть номером телефона.

"!email":{
	"email":"Проверьте, пожалуйста, указанный адрес электронной почты."
},

Адрес электронки для примера не обязателен, проверка данных в поле проходит, только если это поле заполнено. И данные в этом поле обязаны быть адресом электронной почты.

"!comment":{
	"matches":{
		"params":"/^(?:(?!\\w+\\.\\w+).)*$/",
		"message":"Здесь нельзя отправлять ссылки. Извините за неудобства."
	}
}

Поле для ввода сообщения не обязательно к заполнению, проверяется, если заполнено. Формально регулярное выражение проверяет отсутствие ссылок в этом поле. Фактически - запрещены конструкции с точкой. Например, фраза "Яндекс.Директ" тоже не пройдет эту проверку.

"agreeCheck":{
	"required":"Требуется ваше согласие."
}

В свете последних тенденций в законодательстве стоит подстраховаться и требовать согласие с Политикой конфиденциальности как минимум. Здесь мы запрещаем отправку сообщений без установленной галки согласия.

Продолжая тему о защите от спама, расскажу про сниппет checkSpamTimeFL. Я не являюсь автором идеи, оригинал сниппета нашла здесь. Я считаю, что для большинства моих проектов этот сниппет следует изменить. Поскольку намного чаще отправка форм происходит через ajax, на страницу выводится обычно только чанк формы, а сам FormLister вызывается при попытке обработать запрос с данными формы. То есть, значение времени инициализации формы нужно задавать отдельно от FormLister. Я использую для этих целей скрытое поле now в форме и JS. Итак, код сниппета:

<?php
	if ($FormLister->isSubmitted()){
		$flag = false;
		$now = microtime(true);
		$da=floatval($FormLister->getField('now'));
		
		if (($now - $da) > 2){		
			$flag = true;
		}
		$FormLister->setValid($flag);
	}

Теперь посмотрим на чанк с формой.

<form id="feedbackForm" method="post">
	<input type="hidden" name="formid" value="feedbackForm"/>
	<input type="hidden" name="now" value="0"/>
	<div class="form-group">
		<label for="inputName">Имя *</label>
		<input type="text" class="form-control [+name.classname+]" id="inputName" name="name" required="required" value="[+name+]">
		[+name.error+]
	</div>
	<div class="form-row mb-3">
		<div class="form-group col-md-6 mb-0">
			<label for="inputPhone">Телефон *</label>
			<input type="tel" class="form-control [+phone.classname+]" id="inputPhone" name="phone" required="required" aria-describedby="phoneHelpBlock" value="[+phone+]">
			[+phone.error+]
		</div>
		<div class="form-group col-md-6 mb-0">
			<label for="inputEmail">Email</label>
			<input type="email" class="form-control [+email.classname+]" id="inputEmail" name="email" value="[+email+]">
			[+email.error+]
		</div>
		<small id="phoneHelpBlock" class="form-text text-muted">Мы никому не передаем ваши контактные данные. Также мы не отправим вам рассылку, если вы не согласны ее получать. Ваш телефон и электронная почта используются только для связи с вами.</small>
	</div>
	<div class="form-group">
		<label for="inputMessage">Пояснение</label>
		<textarea class="form-control [+comment.classname+]" id="inputMessage" name="comment" aria-describedby="messageHelpBlock">[+comment+]</textarea>
		[+comment.error+]
		<small id="messageHelpBlock" class="form-text text-muted">Укажите здесь ваш вопрос и другие детали, которые вы считаете важными.</small>
	</div>
	<div class="form-group">
		<div class="form-check">
			<input class="form-check-input [+agreeCheck.classname+]" type="checkbox" id="agreeCheck" required="required" value="1" name="agreeCheck">
			<label class="form-check-label" for="agreeCheck">
				Я принимаю <a href="..." target="_blank">Пользовательское соглашение</a> и согласен с <a href="..." target="_blank">Политикой конфиденциальности</a>.
			</label>
			[+agreeCheck.error+]
		</div>
	</div>
	<div class="form-group">
		<div class="form-check">
			<input class="form-check-input [+sendCheck.classname+]" type="checkbox" id="sendCheck" value="1" name="sendCheck">
			<label class="form-check-label" for="sendCheck">
				Я согласен получать информационную рассылку для клиентов.
			</label>
			[+sendCheck.error+]
		</div>
	</div>
	<button type="submit" class="btn btn-primary">Отправить</button>
</form>

Код формы достаточно нагляден, поэтому покажу, как выглядит кнопка, вызывающая модальное окно с формой.

<a class="btn btn-primary" href="#" role="button" data-toggle="modal" data-target="#feedbackModal">Обратная связь</a>

Ну и закончу на этом.