Как реализовать аудит согласия пользователя в MODX + SendIt: от клика по checkbox до защищённого JSONL-журнала

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

Такая задача возникла на одном из сайтов на MODX Revolution 2.8.x с SendIt 2.8.x.

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

В результате получилось четыре типа событий:

consent_checked
consent_unchecked
form_submit_success
form_submit_error

При этом для каждого события сохранялись данные формы, URL страницы, идентификатор формы, IP, User-Agent и общий audit_session_id, позволяющий связать несколько действий одного пользователя в единую последовательность.

Разберём архитектуру и наиболее интересные проблемы, которые пришлось решить.

Задача

На сайте уже существовали рабочие формы SendIt.

Упрощённо одна из них выглядела примерно так:

<form
    data-si-form="callbackForm"
>
    <legend>Заказать обратный звонок</legend>

    <input type="hidden" name="formId" value="6">

    <input type="text" name="name">
    <input type="tel" name="phone">

    <label>
        <input
            type="checkbox"
            name="privacy"
        >
        Я согласен на обработку персональных данных
    </label>

    <button type="submit">
        Отправить
    </button>
</form>
Code language: HTML, XML (xml)

Требовалось получить примерно такую историю:

14:03:12 consent_checked
14:03:18 consent_unchecked
14:03:24 consent_checked
14:03:31 form_submit_success
Code language: CSS (css)

Причём недостаточно было просто знать время события. Нужно было сохранить состояние формы именно в этот момент:

event
datetime
form_id
form_key
form_name
name
phone
email
address
page_url
ip
user_agent
audit_session_id

И здесь появляется первая важная проблема.

Почему нельзя просто читать форму непосредственно перед AJAX-запросом

Предположим, пользователь быстро выполняет три действия:

checked
unchecked
checked

Если каждое событие запускает асинхронный запрос, нет гарантии, что они закончатся на сервере в том же порядке. Более того, пользователь может изменить имя или телефон, пока предыдущий audit-запрос ещё ожидает отправки.

Поэтому важны две вещи:

  1. данные необходимо зафиксировать сразу в момент события;
  2. сами запросы желательно отправлять последовательно.

Получается схема:

Событие
   ↓
Снимок FormData
   ↓
Очередь
   ↓
SendIt
   ↓
Audit preset
   ↓
PHP logger
   ↓
JSONL

Добавляем специальные data-атрибуты

Не хотелось привязывать JavaScript к конкретному имени поля вроде:

form.querySelector('[name="phone"]')
Code language: JavaScript (javascript)

Потому что на другой форме поле может называться:

phone
telephone
tel
contact
user_phone

Поэтому конфигурацию удобнее разместить непосредственно на форме:

<form
    data-consent-audit
    data-si-form="callbackForm"
    data-audit-name-field="name"
    data-audit-phone-field="phone"
    data-audit-email-field="email"
    data-audit-address-field="address"
>
Code language: HTML, XML (xml)

Теперь один JS может обслуживать множество разных форм.

Для формы с универсальным полем «Телефон или Email» можно, например, использовать:

data-audit-contact-field="contact"
Code language: JavaScript (javascript)

и определить тип значения программно:

function getAuditContacts(form, getValue) {
    let phone = '';
    let email = '';

    if (form.dataset.auditPhoneField) {
        phone = getValue(form.dataset.auditPhoneField);
    }

    if (form.dataset.auditEmailField) {
        email = getValue(form.dataset.auditEmailField);
    }

    if (form.dataset.auditContactField) {
        const contact = getValue(
            form.dataset.auditContactField
        ).trim();

        if (contact.includes('@')) {
            email = contact;
            phone = '';
        } else {
            phone = contact;
            email = '';
        }
    }

    return { phone, email };
}
Code language: JavaScript (javascript)

Так аудит не зависит от структуры конкретной формы.

Делегирование событий вместо обработчика на каждой форме

<input
       type="checkbox"
       name="privacy"
       data-consent-checkbox
 >
Code language: HTML, XML (xml)

Формы на сайте могут загружаться динамически — например, появляться в модальном окне после загрузки страницы.

Поэтому вместо:

document
    .querySelectorAll('[data-consent-checkbox]')
    .forEach(...);
Code language: JavaScript (javascript)

удобнее использовать делегирование:

document.addEventListener('change', (event) => {
    const checkbox = event.target;

    if (
        !checkbox.matches?.(
            'input[type="checkbox"][data-consent-checkbox]'
        )
    ) {
        return;
    }

    const form = checkbox.form;

    if (
        !form ||
        !form.hasAttribute('data-consent-audit')
    ) {
        return;
    }

    sendAudit(
        form,
        checkbox.checked
            ? 'consent_checked'
            : 'consent_unchecked'
    );
});
Code language: JavaScript (javascript)

Теперь обработчик автоматически работает и для форм, появившихся позже.

Снимаем состояние формы немедленно

Важный принцип: FormData создаётся не внутри будущего AJAX-запроса, а непосредственно при возникновении события.

Например:

function getFieldValue(form, fieldName) {
    if (!fieldName) {
        return '';
    }

    const formData = new FormData(form);
    const value = formData.get(fieldName);

    if (
        value === null ||
        value instanceof File
    ) {
        return '';
    }

    return String(value);
}
Code language: JavaScript (javascript)

Затем строим audit payload:

function buildAuditData(form, eventName) {
    const params = new FormData();

    params.set('audit_event', eventName);

    params.set(
        'form_id',
        getFieldValue(form, 'formId')
    );

    params.set(
        'form_key',
        form.dataset.siForm ||
        form.dataset.siPreset ||
        ''
    );

    const legend = form.querySelector('legend');

    params.set(
        'form_name',
        legend
            ? legend.textContent.trim()
            : ''
    );

    params.set(
        'audit_name',
        getFieldValue(
            form,
            form.dataset.auditNameField
        )
    );

    const contacts = getAuditContacts(
        form,
        (name) => getFieldValue(form, name)
    );

    params.set(
        'audit_phone',
        contacts.phone
    );

    params.set(
        'audit_email',
        contacts.email
    );

    params.set(
        'audit_address',
        getFieldValue(
            form,
            form.dataset.auditAddressField
        )
    );

    params.set(
        'page_url',
        window.location.href
    );

    return params;
}
Code language: JavaScript (javascript)

То есть после:

const params = buildAuditData(form, eventName);
Code language: JavaScript (javascript)

мы имеем уже зафиксированное состояние.

Если пользователь через 100 мс изменит телефон, предыдущая запись от этого не изменится.

Зачем понадобилась очередь Promise

Следующая проблема — порядок событий.

Если просто делать:

sendRequest(...);
sendRequest(...);
sendRequest(...);

то сетевые запросы могут закончиться в другом порядке.

Поэтому была добавлена простая очередь:

let auditQueue = Promise.resolve();
Code language: JavaScript (javascript)

Отправка события:

function sendAudit(form, eventName) {
    const params = buildAuditData(
        form,
        eventName
    );

    auditQueue = auditQueue
        .catch(() => null)

        .then(async () => {
            const SendIt = await getSendIt();

            return SendIt.Sending.sendRequest(
                form,
                'consentAudit',
                params,
                'send'
            );
        })

        .catch((error) => {
            console.error(
                '[ConsentAudit]',
                error
            );
        });
}
Code language: JavaScript (javascript)

Здесь есть два важных момента.

Первый:

const params = buildAuditData(...);
Code language: JavaScript (javascript)

– находится до очереди. Именно поэтому данные снимаются немедленно.

Второй:

.catch(() => null)
Code language: JavaScript (javascript)

– перед следующим запросом не позволяет одной ошибке уничтожить всю цепочку.

Без этого получится:

request #1rejectrequest #2 → уже не выполняется
request #3 → уже не выполняется
Code language: CSS (css)

А нам нужно:

request #1ERROR
request #2SEND
request #3SEND
Code language: CSS (css)

Аудит не должен ломать основную форму

Это один из главных архитектурных принципов такого решения.

Audit — дополнительная система.

Если по какой-то причине:

consentAudit preset не отвечает
PHP logger сломан
каталог логов недоступен
произошла JS-ошибка

пользователь всё равно должен иметь возможность отправить основную форму.

Поэтому ошибка audit-запроса только логируется:

.catch((error) => {
    console.error(
        '[ConsentAudit]',
        error
    );
});
Code language: JavaScript (javascript)

но не пробрасывается в основной workflow формы.

Как определить успешную отправку формы

С consent_checked всё просто — это браузерное событие. С успешной отправкой ситуация сложнее.

Нельзя считать нажатиe <button type=”submit”> успешной отправкой. Даже само событие submit означает только попытку отправки.

После него сервер может вернуть:

ошибка валидации
ошибка CAPTCHA
ошибка PHP
ошибка hook
ошибка отправки письма

Поэтому form_submit_success логичнее фиксировать на сервере — в hook, который запускается только после успешной обработки основной формы. В списке хуков лучше поставить его последним.

Перед отправкой браузер добавляет в форму технические hidden-поля:

function setHiddenField(form, name, value) {
    let field = form.querySelector(
        `input[type="hidden"][name="${name}"]`
    );

    if (!field) {
        field = document.createElement('input');
        field.type = 'hidden';
        field.name = name;
        form.appendChild(field);
    }

    field.value = value || '';
}
Code language: JavaScript (javascript)

Например:

audit_form_key
audit_form_name
audit_page_url
audit_name
audit_phone
audit_email
audit_address
audit_session_id

Обработчик:

document.addEventListener(
    'submit',
    (event) => {
        const form = event.target;

        if (
            !form.matches?.(
                'form[data-consent-audit]'
            )
        ) {
            return;
        }

        prepareSubmitAuditMetadata(form);
    },
    true
);
Code language: JavaScript (javascript)

После успешной серверной обработки hook записывает:

form_submit_success

Таким образом success означает не «пользователь нажал кнопку», а фактический успешный результат SendIt. То что нам нужно!

Как определить неуспешную отправку

Для ошибки можно воспользоваться событием SendIt после получения результата.

Например:

document.addEventListener(
    'si:send:after',
    (event) => {
        const detail = event.detail;

        if (
            !detail ||
            !detail.result ||
            detail.result.success !== false
        ) {
            return;
        }

        const target = detail.target;

        if (!target) {
            return;
        }

        const form = target.tagName === 'FORM'
            ? target
            : target.closest?.('form');

        if (
            !form ||
            !form.matches(
                'form[data-consent-audit]'
            )
        ) {
            return;
        }

        sendAudit(
            form,
            'form_submit_error'
        );
    }
);
Code language: JavaScript (javascript)

Однако здесь скрывается довольно опасная проблема.

Защита от бесконечной рекурсии

Audit-события сами отправляются через SendIt.

То есть:

основная форма
   ↓
SendIt
   ↓
ошибка
   ↓
form_submit_error
   ↓
SendIt consentAudit

А что если сам consentAudit вернёт ошибку?

Если обработчик ловит вообще все ошибки SendIt, получится:

consentAudit ERROR
       ↓
form_submit_error
       ↓
consentAudit
       ↓
ERROR
       ↓
form_submit_error
       ↓
...

То есть потенциальный бесконечный цикл.

Поэтому audit-запросы необходимо отличать от обычных SendIt-запросов.

В SendIt для этого можно проверить preset:

const AUDIT_PRESET = 'consentAudit';

function isConsentAuditEvent(event) {
    return (
        event.detail?.headers?.['X-SIPRESET']
        === AUDIT_PRESET
    );
}
Code language: JavaScript (javascript)

И первой проверкой в обработчике сделать:

if (isConsentAuditEvent(event)) {
    return;
}
Code language: JavaScript (javascript)

Это небольшая деталь, но без неё дополнительный механизм логирования способен сам создать серьёзную проблему.

Не доверяем событию чужой формы

На странице может существовать несколько SendIt-форм.

Поэтому недостаточно найти:

event.detail.target
Code language: CSS (css)

Желательно также удостовериться, что событие пришло именно от ожидаемого preset.

Например:

const requestPreset =
    event.detail?.headers?.['X-SIPRESET']
    || '';

const formPreset =
    form.dataset.siPreset
    || form.dataset.siForm
    || '';

if (
    !formPreset ||
    requestPreset !== formPreset
) {
    return;
}
Code language: JavaScript (javascript)

Это защищает аудит от ложных совпадений, если несколько SendIt-запросов происходят одновременно.

Один audit_session_id для всей последовательности

Для анализа отдельных событий один audit_session_id недостаточно.

Представим журнал:

12:04 consent_checked  +79990001122
12:05 consent_checked  +79990002233
12:06 form_submit_success +79990001122
Code language: CSS (css)

Как точно понять, какие события относятся друг к другу?

Для этого каждому взаимодействию формы назначается audit_session_id.

В современном браузере его можно получить так:

function createAuditSessionId() {
    if (crypto.randomUUID) {
        return crypto.randomUUID();
    }

    return (
        Date.now().toString(36)
        + '-'
        + Math.random().toString(36).slice(2)
    );
}
Code language: JavaScript (javascript)

После этого идентификатор нужно сохранить для формы и добавлять ко всем событиям:

params.set(
    'audit_session_id',
    getAuditSessionId(form)
);
Code language: JavaScript (javascript)

В журнале появляется цепочка:

session: 8c52...

14:03:12 consent_checked
14:03:18 consent_unchecked
14:03:24 consent_checked
14:03:31 form_submit_success
Code language: CSS (css)

Это гораздо информативнее отдельных независимых записей.


Почему для журнала был выбран JSONL

Следующий вопрос: куда всё сохранять? Можно создать отдельную таблицу MySQL. Но для сравнительно небольшого audit-журнала это не всегда необходимо.

Был выбран формат JSON Lines. В отличие от обычного JSON-файла:

[
    {...},
    {...},
    {...}
]

в JSONL каждая строка является самостоятельным JSON-объектом:

{"event":"consent_checked","datetime":"2026-09-17T15:23:10+03:00"}
{"event":"consent_unchecked","datetime":"2026-09-17T15:23:13+03:00"}
{"event":"form_submit_success","datetime":"2026-09-17T15:23:18+03:00"}
Code language: JSON / JSON with Comments (json)

Преимущество очевидно: чтобы добавить запись, не нужно читать и перезаписывать весь файл.

Достаточно:

file_put_contents(
    $filename,
    json_encode(
        $record,
        JSON_UNESCAPED_UNICODE |
        JSON_UNESCAPED_SLASHES
    ) . PHP_EOL,
    FILE_APPEND | LOCK_EX
);
Code language: PHP (php)

LOCK_EX здесь важен, потому что одновременно могут прийти несколько запросов.

Разделяем журналы по месяцам

Хранить всё в одном бесконечно растущем файле тоже неудобно.

Поэтому журнал можно разбить:

2026-09-consent.log
2026-10-consent.log
2026-11-consent.log
Code language: CSS (css)

Упрощённый logger:

class ConsentAuditLogger
{
    protected modX $modx;
    protected string $logDir;
    protected DateTimeZone $timezone;

    public function __construct(
        modX $modx,
        string $logDir
    ) {
        $this->modx = $modx;
        $this->logDir = rtrim(
            $logDir,
            '/\\'
        );

        $this->timezone =
            new DateTimeZone('Europe/Moscow');
    }

    public function write(array $data): bool
    {
        $now = new DateTime(
            'now',
            $this->timezone
        );

        $file = sprintf(
            '%s/%s-consent.log',
            $this->logDir,
            $now->format('Y-m')
        );

        $record = array_merge(
            [
                'datetime' =>
                    $now->format(DateTime::ATOM),
            ],
            $data
        );

        $json = json_encode(
            $record,
            JSON_UNESCAPED_UNICODE |
            JSON_UNESCAPED_SLASHES
        );

        if ($json === false) {
            return false;
        }

        return file_put_contents(
            $file,
            $json . PHP_EOL,
            FILE_APPEND | LOCK_EX
        ) !== false;
    }
}
Code language: PHP (php)

Путь к журналу лучше не прописывать непосредственно в snippet:

/core/cache/...

а вынести в системную настройку MODX:

consent_audit_log_path

Тогда:

$logPath = $modx->getOption(
    'consent_audit_log_path'
);
Code language: PHP (php)

Что записывать на сервере

Упрощённая запись может выглядеть так:

$logger->write([
    'event' => $event,
    'form_id' => $formId,
    'form_key' => $formKey,
    'form_name' => $formName,

    'name' => $name,
    'phone' => $phone,
    'email' => $email,
    'address' => $address,

    'url' => $pageUrl,

    'ip' =>
        $_SERVER['REMOTE_ADDR'] ?? '',

    'user_agent' =>
        $_SERVER['HTTP_USER_AGENT'] ?? '',

    'audit_session_id' =>
        $auditSessionId,
]);
Code language: PHP (php)

При этом данные, пришедшие от браузера, всё равно нужно считать недоверенными.

Например:

page_url
form_name
form_id
audit_session_id

клиент технически может изменить самостоятельно.

Audit-журнал фиксирует полученные события, но не превращает браузерные значения в криптографически достоверные данные.

Это важно понимать, если журнал используется не только для отладки, но и для более серьёзного аудита.

Неожиданная проблема: SendIt изменял значения полей

Во время тестирования обнаружилась ещё одна интересная проблема.

В audit-журнал нужно было записывать то значение, которое реально ввёл пользователь.

Но SendIt пропускает поля через собственную обработку и sanitization.

Для основной формы это нормальное и полезное поведение.

Для audit snapshot оно может быть нежелательным.

Например, если пользователь ввёл некорректный email, именно этот некорректный email представляет интерес для события:

<code>form_submit_error</code>Code language: HTML, XML (xml)

Но после обработки SendIt значение могло оказаться изменённым.

Получалась ситуация:

Пользователь ввёл:
test@@example.com

Валидация:
ERROR

В audit:
уже преобразованное значениеCode language: CSS (css)

А для аудита важно: Что пользователь ввёл в момент ошибки?

Решением стало сохранение исходных audit_* значений до того, как стандартная обработка SendIt их изменит.

Для этого можно использовать событие обработки значений SendIt и отдельно защищать технические поля:

audit_name
audit_phone
audit_email
audit_address

То есть концептуально:

if (
    strpos($fieldName, 'audit_') === 0
) {
    // Для audit-полей сохраняем исходное
    // значение, а не преобразованное.
}Code language: PHP (php)

Это хороший пример того, почему audit-система не должна слепо использовать уже обработанные значения основной бизнес-логики.

Валидация отвечает на вопрос «можно ли принять эти данные».

Аудит отвечает на вопрос «что произошло».

Это разные задачи.

Почему form_submit_success лучше фиксировать на сервере

Можно было сделать:

if (result.success === true) {
    sendAudit(
        form,
        'form_submit_success'
    );
}Code language: JavaScript (javascript)

Но серверный hook надёжнее.

Он позволяет связать success с фактической обработкой формы:

browser
   ↓
SendIt
   ↓
validation
   ↓
hooks
   ↓
основная операция
   ↓
ConsentAuditSuccessHook
   ↓
JSONL

Тогда запись form_submit_success создаётся внутри той же серверной логики, которая уже знает результат формы.

А form_submit_error, наоборот, удобно фиксировать после ответа SendIt, поскольку именно браузер получает success:false и сохраняет snapshot неудачной попытки.

В итоге система получается асимметричной:

consent_checked       → JS → audit preset
consent_unchecked     → JS → audit preset
form_submit_error     → JS → audit preset
form_submit_success   → server hook

И это в данном случае скорее преимущество, чем недостаток.

Отдельный Viewer вместо просмотра файлов вручную

JSONL удобно писать, но не очень удобно ежедневно читать.

Поэтому был сделан небольшой read-only Viewer.

Он:

  • находит доступные файлы журналов;
  • позволяет выбирать год и месяц;
  • фильтрует по событию;
  • ищет по форме;
  • ищет по контакту;
  • фильтрует по IP;
  • фильтрует по URL;
  • поддерживает общий поиск;
  • сортирует колонки;
  • показывает записи постранично.

При открытии файл читается построчно:

$handle = fopen(
    $logFile,
    'rb'
);

while (
    ($line = fgets($handle)) !== false
) {
    $line = trim($line);

    if ($line === '') {
        continue;
    }

    $row = json_decode(
        $line,
        true
    );

    if (!is_array($row)) {
        continue;
    }

    // Фильтрация...

    $rows[] = $row;
}

fclose($handle);
Code language: PHP (php)

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

Если объём вырастет до миллионов событий, логичнее будет перейти на базу данных или специализированное log storage.

Самое важное в Viewer — не интерфейс, а доступ

Журнал содержит:

имя
телефон
email
IP
URL
User-Agent

Поэтому оставлять PHP-файл Viewer публично доступным нельзя.

При standalone Viewer можно загрузить MODX в manager-контексте:

require_once $siteRoot . '/config.core.php';
require_once MODX_CORE_PATH
    . 'model/modx/modx.class.php';

$modx = new modX();
$modx->initialize('mgr');
Code language: PHP (php)

После чего проверить manager session:

if (
    !$modx->user ||
    !$modx->user->hasSessionContext('mgr')
) {
    http_response_code(403);
    exit('Доступ запрещён');
}Code language: PHP (php)

В нашем случае доступ дополнительно был ограничен sudo:

if (!(bool)$modx->user->get('sudo')) {
    http_response_code(403);
    exit('Доступ запрещён');
}Code language: PHP (php)

Таким образом знание URL Viewer само по себе ничего не даёт.

HTML обязательно экранируем

Это особенно важно для audit viewer.

В журнал может попасть пользовательское значение вроде:

<script>alert(1)</script>Code language: HTML, XML (xml)

Поэтому выводить:

echo $row['name'];Code language: PHP (php)

нельзя.

Используем:

function h($value): string
{
    return htmlspecialchars(
        (string)$value,
        ENT_QUOTES | ENT_SUBSTITUTE,
        'UTF-8'
    );
}
Code language: PHP (php)

и затем:

<?= h($row['name'] ?? '') ?>
Code language: HTML, XML (xml)

Audit Viewer должен рассматривать содержимое журнала как недоверенные данные.


Что получилось в итоге

Архитектура стала выглядеть следующим образом:

Как реализовать аудит согласия пользователя в MODX + SendIt: от клика по checkbox до защищённого JSONL-журнала

Что обязательно нужно протестировать перед запуском

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

Сценарий 1. Только согласие

поставить checkbox

Ожидаем:

consent_checked

Сценарий 2. Отмена согласия

checked
unchecked

Ожидаем:

consent_checked
consent_unchecked

с одним audit_session_id.


Сценарий 3. Быстрое переключение

checked
unchecked
checked

В журнале порядок должен сохраниться:

1 consent_checked
2 consent_unchecked
3 consent_checked

Сценарий 4. Успешная форма

checked
submit

Ожидаем:

consent_checked
form_submit_success

Сценарий 5. Ошибка валидации

Например, некорректный email:

checked
submit

Ожидаем:

consent_checked
form_submit_error

а в audit_email — именно значение, которое пользователь пытался отправить.


Сценарий 6. Ошибка самого audit preset

Искусственно ломаем consentAudit.

Основная форма всё равно должна продолжать работать.

И самое важное — не должно появиться бесконечного:

form_submit_error
form_submit_error
form_submit_error
...

Основные выводы

На первый взгляд задача выглядела просто: «записать, когда пользователь поставил галочку».

Но полноценный аудит формы требует учитывать асинхронность браузера, динамические формы, порядок событий, серверную валидацию, sanitization, ошибки самого audit-механизма и безопасность просмотра журнала.

Наиболее важными решениями оказались:

  1. Снимать состояние формы непосредственно в момент события, а не позже во время фактической отправки AJAX-запроса.
  2. Отправлять audit-события через последовательную Promise-очередь, чтобы сохранить их порядок.
  3. Не позволять сбою аудита ломать основную форму.
  4. Исключать собственный audit preset из обработки ошибок, иначе легко получить рекурсивный цикл.
  5. Фиксировать успешную отправку на сервере, а не считать событие submit доказательством успеха.
  6. Сохранять исходные значения отдельно от обычной sanitization SendIt, если журнал должен показывать фактический пользовательский ввод.
  7. Связывать действия через audit_session_id, чтобы видеть не набор отдельных записей, а историю одной попытки.
  8. Хранить журнал отдельно и защищать Viewer авторизацией, поскольку лог содержит персональные данные.

В итоге из небольшого обработчика checkbox получилась полноценная событийная система аудита, которая при этом практически не вмешивается в существующую архитектуру SendIt и может постепенно подключаться к разным формам через несколько data-* атрибутов.

Понравилась статья? Поделиться с друзьями:
Добавить комментарий

;-) :| :x :twisted: :smile: :shock: :sad: :roll: :razz: :oops: :o :mrgreen: :lol: :idea: :grin: :evil: :cry: :cool: :arrow: :???: :?: :!: