2025-03-11 23:50:50 +03:00
|
|
|
|
/* global $, window, document, showAlert */
|
|
|
|
|
$(document).ready(function() {
|
|
|
|
|
const publicLink = window.location.pathname.split('/').pop();
|
|
|
|
|
let questionnaireData = null;
|
|
|
|
|
|
|
|
|
|
// Элементы DOM
|
|
|
|
|
const loadingEl = $('#loading');
|
|
|
|
|
const containerEl = $('#questionnaire-container');
|
|
|
|
|
const titleEl = $('#questionnaire-title');
|
|
|
|
|
const descriptionEl = $('#questionnaire-description');
|
|
|
|
|
const questionsContainerEl = $('#questions-container');
|
|
|
|
|
const formEl = $('#poll-form');
|
|
|
|
|
const resultsContainerEl = $('#results-container');
|
|
|
|
|
const pollResultsContainerEl = $('#poll-results-container');
|
|
|
|
|
|
|
|
|
|
// Элементы навигации для пошаговых опросов
|
|
|
|
|
const navigationControlsEl = $('#navigation-controls');
|
|
|
|
|
const prevButtonEl = $('#prev-question');
|
|
|
|
|
const nextButtonEl = $('#next-question');
|
|
|
|
|
const questionCounterEl = $('#question-counter');
|
|
|
|
|
const submitButtonEl = $('#submit-button');
|
|
|
|
|
|
|
|
|
|
// Для пошаговых опросов
|
|
|
|
|
let currentQuestionIndex = 0;
|
|
|
|
|
|
2025-03-12 00:09:36 +03:00
|
|
|
|
// Объявляем переменную для хранения накопленных ответов
|
|
|
|
|
let accumulatedAnswers = [];
|
|
|
|
|
|
2025-03-11 23:50:50 +03:00
|
|
|
|
// Проверка доступности localStorage
|
|
|
|
|
const isLocalStorageAvailable = () => {
|
|
|
|
|
try {
|
|
|
|
|
const testKey = 'test';
|
|
|
|
|
window.localStorage.setItem(testKey, testKey);
|
|
|
|
|
window.localStorage.removeItem(testKey);
|
|
|
|
|
return true;
|
|
|
|
|
} catch (e) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Ключ для localStorage
|
|
|
|
|
const getLocalStorageKey = () => `questionnaire_${publicLink}_completed`;
|
|
|
|
|
|
|
|
|
|
// Проверка на повторное прохождение опроса
|
|
|
|
|
const checkIfAlreadyCompleted = () => {
|
|
|
|
|
if (!isLocalStorageAvailable()) return false;
|
|
|
|
|
return window.localStorage.getItem(getLocalStorageKey()) === 'true';
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Сохранение информации о прохождении опроса
|
|
|
|
|
const markAsCompleted = () => {
|
|
|
|
|
if (!isLocalStorageAvailable()) return;
|
|
|
|
|
window.localStorage.setItem(getLocalStorageKey(), 'true');
|
|
|
|
|
};
|
|
|
|
|
|
2025-03-12 00:09:36 +03:00
|
|
|
|
// Функция для получения базового пути API
|
2025-03-11 23:50:50 +03:00
|
|
|
|
const getApiPath = () => {
|
2025-03-12 00:09:36 +03:00
|
|
|
|
// Проверяем, содержит ли путь /ms/ (продакшн на dev.bro-js.ru)
|
|
|
|
|
const pathname = window.location.pathname;
|
|
|
|
|
const isMsPath = pathname.includes('/ms/questioneer');
|
|
|
|
|
|
|
|
|
|
if (isMsPath) {
|
|
|
|
|
// Для продакшна: если в пути есть /ms/, то API доступно по /ms/questioneer/api
|
|
|
|
|
return '/ms/questioneer/api';
|
|
|
|
|
} else {
|
|
|
|
|
// Для локальной разработки: используем обычный путь
|
|
|
|
|
// Извлекаем базовый путь из URL страницы
|
|
|
|
|
const pathParts = pathname.split('/');
|
|
|
|
|
// Убираем slug и последний сегмент (poll/[id])
|
|
|
|
|
if (pathParts.length > 2) {
|
|
|
|
|
// Удаляем 2 последних сегмента пути (poll/[id])
|
|
|
|
|
pathParts.pop();
|
|
|
|
|
pathParts.pop();
|
|
|
|
|
}
|
|
|
|
|
// Если последний сегмент пустой (из-за /) - удаляем его
|
|
|
|
|
if (pathParts[pathParts.length - 1] === '') {
|
|
|
|
|
pathParts.pop();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Путь до корня приложения
|
|
|
|
|
return pathParts.join('/') + '/api';
|
|
|
|
|
}
|
2025-03-11 23:50:50 +03:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Загрузка данных опроса
|
|
|
|
|
const loadQuestionnaire = () => {
|
|
|
|
|
$.ajax({
|
|
|
|
|
url: `${getApiPath()}/questionnaires/public/${publicLink}`,
|
|
|
|
|
method: 'GET',
|
|
|
|
|
success: function(result) {
|
|
|
|
|
if (result.success) {
|
|
|
|
|
questionnaireData = result.data;
|
|
|
|
|
|
|
|
|
|
// Проверяем, проходил ли пользователь уже этот опрос
|
|
|
|
|
if (checkIfAlreadyCompleted()) {
|
|
|
|
|
showAlreadyCompletedMessage();
|
|
|
|
|
} else {
|
|
|
|
|
showWelcomeAnimation();
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
loadingEl.text(`Ошибка: ${result.error}`);
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
error: function(error) {
|
|
|
|
|
console.error('Error loading questionnaire:', error);
|
|
|
|
|
loadingEl.text('Не удалось загрузить опрос. Пожалуйста, попробуйте позже.');
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Показываем анимацию приветствия
|
|
|
|
|
const showWelcomeAnimation = () => {
|
|
|
|
|
// Скрываем индикатор загрузки
|
|
|
|
|
loadingEl.hide();
|
|
|
|
|
|
|
|
|
|
// Создаем элемент приветствия
|
|
|
|
|
const $welcomeEl = $('<div>', {
|
|
|
|
|
id: 'welcome-animation',
|
|
|
|
|
class: 'welcome-animation',
|
|
|
|
|
css: {
|
|
|
|
|
opacity: 0,
|
|
|
|
|
transform: 'translateY(20px)'
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const $welcomeIcon = $('<div>', {
|
|
|
|
|
class: 'welcome-icon',
|
|
|
|
|
html: '<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" fill="currentColor" viewBox="0 0 16 16"><path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14zm0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16z"/><path d="M4.285 9.567a.5.5 0 0 1 .683.183A3.498 3.498 0 0 0 8 11.5a3.498 3.498 0 0 0 3.032-1.75.5.5 0 1 1 .866.5A4.498 4.498 0 0 1 8 12.5a4.498 4.498 0 0 1-3.898-2.25.5.5 0 0 1 .183-.683zM7 6.5C7 7.328 6.552 8 6 8s-1-.672-1-1.5S5.448 5 6 5s1 .672 1 1.5zm4 0c0 .828-.448 1.5-1 1.5s-1-.672-1-1.5S9.448 5 10 5s1 .672 1 1.5z"/></svg>'
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const $welcomeTitle = $('<h2>', {
|
|
|
|
|
text: `Добро пожаловать в опрос "${questionnaireData.title}"`,
|
|
|
|
|
class: 'welcome-title'
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const $welcomeDescription = $('<p>', {
|
|
|
|
|
text: questionnaireData.description || 'Пожалуйста, ответьте на следующие вопросы:',
|
|
|
|
|
class: 'welcome-description'
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const $startButton = $('<button>', {
|
|
|
|
|
type: 'button',
|
|
|
|
|
text: 'Начать опрос',
|
|
|
|
|
class: 'btn btn-primary welcome-start-btn'
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
$startButton.on('click', function() {
|
|
|
|
|
$welcomeEl.css({
|
|
|
|
|
opacity: 0,
|
|
|
|
|
transform: 'translateY(-20px)'
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
$welcomeEl.remove();
|
|
|
|
|
renderQuestionnaire();
|
|
|
|
|
}, 500);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
$welcomeEl.append($welcomeIcon, $welcomeTitle, $welcomeDescription, $startButton);
|
|
|
|
|
|
|
|
|
|
// Добавляем приветствие перед контейнером опроса
|
|
|
|
|
containerEl.before($welcomeEl);
|
|
|
|
|
|
|
|
|
|
// Анимируем появление
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
$welcomeEl.css({
|
|
|
|
|
opacity: 1,
|
|
|
|
|
transform: 'translateY(0)'
|
|
|
|
|
});
|
|
|
|
|
}, 100);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Показываем сообщение о том, что опрос уже пройден
|
|
|
|
|
const showAlreadyCompletedMessage = () => {
|
|
|
|
|
loadingEl.hide();
|
|
|
|
|
|
|
|
|
|
const $completedEl = $('<div>', {
|
|
|
|
|
class: 'already-completed',
|
|
|
|
|
css: {
|
|
|
|
|
opacity: 0,
|
|
|
|
|
transform: 'translateY(20px)'
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const $completedIcon = $('<div>', {
|
|
|
|
|
class: 'completed-icon',
|
|
|
|
|
html: '<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" fill="currentColor" viewBox="0 0 16 16"><path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14zm0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16z"/><path d="M10.97 4.97a.235.235 0 0 0-.02.022L7.477 9.417 5.384 7.323a.75.75 0 0 0-1.06 1.06L6.97 11.03a.75.75 0 0 0 1.079-.02l3.992-4.99a.75.75 0 0 0-1.071-1.05z"/></svg>'
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const $completedTitle = $('<h2>', {
|
|
|
|
|
text: 'Вы уже прошли этот опрос',
|
|
|
|
|
class: 'completed-title'
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const $completedDescription = $('<p>', {
|
|
|
|
|
text: 'Спасибо за ваше участие. Вы уже отправили свои ответы на этот опрос.',
|
|
|
|
|
class: 'completed-description'
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const $viewResultsButton = $('<button>', {
|
|
|
|
|
type: 'button',
|
|
|
|
|
text: 'Посмотреть результаты',
|
|
|
|
|
class: 'btn btn-primary view-results-btn'
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const $startAgainButton = $('<button>', {
|
|
|
|
|
type: 'button',
|
|
|
|
|
text: 'Пройти снова',
|
|
|
|
|
class: 'btn btn-secondary start-again-btn'
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
$viewResultsButton.on('click', function() {
|
|
|
|
|
$completedEl.remove();
|
|
|
|
|
containerEl.show();
|
|
|
|
|
formEl.hide();
|
|
|
|
|
resultsContainerEl.show();
|
|
|
|
|
loadPollResults();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
$startAgainButton.on('click', function() {
|
|
|
|
|
if (isLocalStorageAvailable()) {
|
|
|
|
|
window.localStorage.removeItem(getLocalStorageKey());
|
|
|
|
|
}
|
|
|
|
|
$completedEl.remove();
|
|
|
|
|
showWelcomeAnimation();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
$completedEl.append($completedIcon, $completedTitle, $completedDescription, $viewResultsButton, $startAgainButton);
|
|
|
|
|
|
|
|
|
|
containerEl.before($completedEl);
|
|
|
|
|
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
$completedEl.css({
|
|
|
|
|
opacity: 1,
|
|
|
|
|
transform: 'translateY(0)'
|
|
|
|
|
});
|
|
|
|
|
}, 100);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Отображение опроса
|
|
|
|
|
const renderQuestionnaire = () => {
|
|
|
|
|
titleEl.text(questionnaireData.title);
|
|
|
|
|
descriptionEl.text(questionnaireData.description || '');
|
|
|
|
|
|
|
|
|
|
// Удаляем кнопку "Отправить ответы", так как есть кнопка "Отправить" в навигации
|
|
|
|
|
formEl.find('.form-actions').remove();
|
|
|
|
|
|
|
|
|
|
// Добавляем навигацию для пошагового опроса
|
|
|
|
|
formEl.append(`
|
|
|
|
|
<div class="step-navigation">
|
|
|
|
|
<button type="button" id="prev-question" class="btn btn-secondary nav-btn">
|
|
|
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
|
|
|
|
|
<path fill-rule="evenodd" d="M11.354 1.646a.5.5 0 0 1 0 .708L5.707 8l5.647 5.646a.5.5 0 0 1-.708.708l-6-6a.5.5 0 0 1 0-.708l6-6a.5.5 0 0 1 .708 0z"/>
|
|
|
|
|
</svg>
|
|
|
|
|
Назад
|
|
|
|
|
</button>
|
2025-03-12 00:09:36 +03:00
|
|
|
|
<span id="question-counter" class="question-counter">Вопрос 1 из ${questionnaireData.questions.length}</span>
|
2025-03-11 23:50:50 +03:00
|
|
|
|
<button type="button" id="next-question" class="btn nav-btn">
|
|
|
|
|
Далее
|
|
|
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
|
|
|
|
|
<path fill-rule="evenodd" d="M4.646 1.646a.5.5 0 0 1 .708 0l6 6a.5.5 0 0 1 0 .708l-6 6a.5.5 0 0 1-.708-.708L10.293 8 4.646 2.354a.5.5 0 0 1 0-.708z"/>
|
|
|
|
|
</svg>
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
`);
|
|
|
|
|
|
|
|
|
|
// Обработчики для навигации
|
|
|
|
|
$('#prev-question').on('click', prevQuestion);
|
|
|
|
|
$('#next-question').on('click', nextQuestion);
|
|
|
|
|
|
|
|
|
|
// Показываем только первый вопрос
|
|
|
|
|
currentQuestionIndex = 0;
|
|
|
|
|
renderCurrentQuestion();
|
|
|
|
|
|
|
|
|
|
// Показываем контейнер с данными
|
|
|
|
|
containerEl.show();
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Отображение текущего вопроса в пошаговом режиме
|
|
|
|
|
const renderCurrentQuestion = () => {
|
|
|
|
|
// Очищаем контейнер вопросов
|
|
|
|
|
questionsContainerEl.empty();
|
|
|
|
|
|
|
|
|
|
// Получаем текущий вопрос
|
|
|
|
|
const currentQuestion = questionnaireData.questions[currentQuestionIndex];
|
|
|
|
|
|
|
|
|
|
// Отображаем вопрос
|
|
|
|
|
renderQuestion(questionsContainerEl, currentQuestion, currentQuestionIndex);
|
|
|
|
|
|
|
|
|
|
// Добавляем классы для анимации
|
|
|
|
|
const $currentQuestionEl = questionsContainerEl.find(`.question-item[data-index="${currentQuestionIndex}"]`);
|
|
|
|
|
$currentQuestionEl.addClass('enter');
|
|
|
|
|
|
|
|
|
|
// Запускаем анимацию появления после небольшой задержки
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
$currentQuestionEl.removeClass('enter').addClass('active');
|
|
|
|
|
}, 50);
|
|
|
|
|
|
|
|
|
|
// Обновляем счетчик вопросов
|
|
|
|
|
questionCounterEl.addClass('update');
|
|
|
|
|
questionCounterEl.text(`Вопрос ${currentQuestionIndex + 1} из ${questionnaireData.questions.length}`);
|
|
|
|
|
|
|
|
|
|
// Снимаем класс анимации счетчика
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
questionCounterEl.removeClass('update');
|
|
|
|
|
}, 500);
|
|
|
|
|
|
|
|
|
|
// Управляем состоянием кнопок навигации
|
|
|
|
|
prevButtonEl.prop('disabled', currentQuestionIndex === 0);
|
|
|
|
|
nextButtonEl.text(
|
|
|
|
|
currentQuestionIndex === questionnaireData.questions.length - 1
|
|
|
|
|
? 'Отправить'
|
|
|
|
|
: 'Далее'
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Переход к предыдущему вопросу
|
|
|
|
|
const prevQuestion = function() {
|
|
|
|
|
if (currentQuestionIndex > 0) {
|
|
|
|
|
currentQuestionIndex--;
|
|
|
|
|
renderCurrentQuestion();
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Переход к следующему вопросу или завершение опроса
|
|
|
|
|
const nextQuestion = function() {
|
|
|
|
|
const currentQuestion = questionnaireData.questions[currentQuestionIndex];
|
|
|
|
|
|
|
|
|
|
// Проверяем, что на текущий вопрос есть ответ, если он обязательный
|
|
|
|
|
if (currentQuestion.required && !checkQuestionAnswer(currentQuestion, currentQuestionIndex)) {
|
|
|
|
|
// Добавляем класс ошибки к текущему вопросу
|
|
|
|
|
const $currentQuestionEl = questionsContainerEl.find(`.question-item[data-index="${currentQuestionIndex}"]`);
|
|
|
|
|
$currentQuestionEl.addClass('error shake');
|
|
|
|
|
|
|
|
|
|
// Удаляем класс ошибки после окончания анимации
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
$currentQuestionEl.removeClass('shake');
|
|
|
|
|
}, 500);
|
|
|
|
|
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2025-03-12 00:09:36 +03:00
|
|
|
|
// Сохраняем ответ на текущий вопрос
|
|
|
|
|
saveCurrentAnswer(currentQuestion, currentQuestionIndex);
|
|
|
|
|
|
2025-03-11 23:50:50 +03:00
|
|
|
|
// Если есть еще вопросы, переходим к следующему
|
|
|
|
|
if (currentQuestionIndex < questionnaireData.questions.length - 1) {
|
|
|
|
|
// Анимируем текущий вопрос перед переходом к следующему
|
|
|
|
|
questionsContainerEl.find(`.question-item[data-index="${currentQuestionIndex}"]`).addClass('exit');
|
|
|
|
|
|
|
|
|
|
// Увеличиваем индекс текущего вопроса
|
|
|
|
|
currentQuestionIndex++;
|
|
|
|
|
|
|
|
|
|
// Рендерим новый вопрос после небольшой задержки
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
renderCurrentQuestion();
|
|
|
|
|
}, 200);
|
|
|
|
|
} else {
|
|
|
|
|
// Анимируем контейнер перед отправкой формы
|
|
|
|
|
questionsContainerEl.find('.question-item').addClass('exit');
|
|
|
|
|
|
|
|
|
|
// Последний вопрос, отправляем форму
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
// Отключаем атрибут required у невидимых полей перед отправкой
|
|
|
|
|
$('input[required]:not(:visible), textarea[required]:not(:visible)').prop('required', false);
|
|
|
|
|
|
|
|
|
|
submitForm();
|
|
|
|
|
}, 300);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2025-03-12 00:09:36 +03:00
|
|
|
|
// Функция для сохранения ответа на текущий вопрос
|
|
|
|
|
const saveCurrentAnswer = (question, questionIndex) => {
|
|
|
|
|
// Нормализуем тип вопроса для согласованной обработки
|
|
|
|
|
const questionType = normalizeQuestionType(question.type);
|
|
|
|
|
const $questionEl = $(`.question-item[data-index="${questionIndex}"]`);
|
|
|
|
|
|
|
|
|
|
// Удаляем предыдущий ответ на этот вопрос, если он был
|
|
|
|
|
accumulatedAnswers = accumulatedAnswers.filter(answer => answer.questionIndex !== questionIndex);
|
|
|
|
|
|
|
|
|
|
let optionIndices;
|
|
|
|
|
let selectedOption;
|
|
|
|
|
let selectedOptions;
|
|
|
|
|
let selectedTags;
|
|
|
|
|
let textAnswer;
|
|
|
|
|
let scaleValue;
|
|
|
|
|
|
|
|
|
|
switch (questionType) {
|
|
|
|
|
case 'single':
|
|
|
|
|
selectedOption = $questionEl.find(`input[name="question_${questionIndex}"]:checked`);
|
|
|
|
|
if (selectedOption.length) {
|
|
|
|
|
accumulatedAnswers.push({
|
|
|
|
|
questionIndex,
|
|
|
|
|
optionIndices: [parseInt(selectedOption.val())]
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
break;
|
|
|
|
|
|
|
|
|
|
case 'multiple':
|
|
|
|
|
selectedOptions = $questionEl.find('input[type="checkbox"]:checked:not(.multiple-choice-validation)');
|
|
|
|
|
if (selectedOptions.length) {
|
|
|
|
|
optionIndices = $.map(selectedOptions, function(option) {
|
|
|
|
|
const nameParts = $(option).attr('name').split('_');
|
|
|
|
|
return parseInt(nameParts[nameParts.length - 1]);
|
|
|
|
|
});
|
|
|
|
|
accumulatedAnswers.push({
|
|
|
|
|
questionIndex,
|
|
|
|
|
optionIndices
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
break;
|
|
|
|
|
|
|
|
|
|
case 'text':
|
|
|
|
|
textAnswer = $questionEl.find('textarea').val().trim();
|
|
|
|
|
if (textAnswer) {
|
|
|
|
|
accumulatedAnswers.push({
|
|
|
|
|
questionIndex,
|
|
|
|
|
textAnswer
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
break;
|
|
|
|
|
|
|
|
|
|
case 'scale':
|
|
|
|
|
case 'rating': // Обратная совместимость
|
|
|
|
|
selectedOption = $questionEl.find(`input[name="question_${questionIndex}"]:checked`);
|
|
|
|
|
if (selectedOption.length) {
|
|
|
|
|
const scaleValue = parseInt(selectedOption.val());
|
|
|
|
|
accumulatedAnswers.push({
|
|
|
|
|
questionIndex,
|
|
|
|
|
scaleValue,
|
|
|
|
|
optionIndices: [scaleValue]
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
break;
|
|
|
|
|
|
|
|
|
|
case 'tagcloud':
|
|
|
|
|
selectedTags = $questionEl.find('.tag-item.selected');
|
|
|
|
|
if (selectedTags.length) {
|
|
|
|
|
const tagTexts = $.map(selectedTags, function(tag) {
|
|
|
|
|
return $(tag).text().replace('×', '').trim();
|
|
|
|
|
});
|
|
|
|
|
accumulatedAnswers.push({
|
|
|
|
|
questionIndex,
|
|
|
|
|
tagTexts
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Отправка формы
|
|
|
|
|
const submitForm = function() {
|
|
|
|
|
// Отключаем атрибут required у невидимых полей перед отправкой
|
|
|
|
|
$('input[required]:not(:visible), textarea[required]:not(:visible)').prop('required', false);
|
|
|
|
|
|
|
|
|
|
// Отправляем накопленные ответы на сервер
|
|
|
|
|
$.ajax({
|
|
|
|
|
url: `${getApiPath()}/vote/${publicLink}`,
|
|
|
|
|
method: 'POST',
|
|
|
|
|
contentType: 'application/json',
|
|
|
|
|
data: JSON.stringify({ answers: accumulatedAnswers }),
|
|
|
|
|
success: function(result) {
|
|
|
|
|
if (result.success) {
|
|
|
|
|
// Сохраняем информацию о прохождении опроса
|
|
|
|
|
markAsCompleted();
|
|
|
|
|
|
|
|
|
|
// Показываем анимацию благодарности вместо сразу скрытия формы
|
|
|
|
|
showThankYouAnimation();
|
|
|
|
|
} else {
|
|
|
|
|
showAlert(`Ошибка: ${result.error}`, 'Ошибка');
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
error: function(error) {
|
|
|
|
|
console.error('Error submitting poll:', error);
|
|
|
|
|
showAlert('Не удалось отправить ответы. Пожалуйста, попробуйте позже.', 'Ошибка');
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
2025-03-11 23:50:50 +03:00
|
|
|
|
// Проверка наличия ответа на вопрос
|
|
|
|
|
const checkQuestionAnswer = (question, questionIndex) => {
|
|
|
|
|
switch (question.type) {
|
|
|
|
|
case 'single_choice':
|
|
|
|
|
return $(`.question-item[data-index="${questionIndex}"] input[name="question_${questionIndex}"]:checked`).length > 0;
|
|
|
|
|
|
|
|
|
|
case 'multiple_choice':
|
|
|
|
|
return $(`.question-item[data-index="${questionIndex}"] input[type="checkbox"]:checked:not(.multiple-choice-validation)`).length > 0;
|
|
|
|
|
|
|
|
|
|
case 'text':
|
|
|
|
|
return $(`.question-item[data-index="${questionIndex}"] textarea`).val().trim() !== '';
|
|
|
|
|
|
|
|
|
|
case 'scale':
|
|
|
|
|
return $(`.question-item[data-index="${questionIndex}"] input[name="question_${questionIndex}"]:checked`).length > 0;
|
|
|
|
|
|
|
|
|
|
case 'tag_cloud':
|
|
|
|
|
return $(`.question-item[data-index="${questionIndex}"] .tag-item.selected`).length > 0;
|
|
|
|
|
|
|
|
|
|
default:
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Отображение всех вопросов (стандартный режим)
|
|
|
|
|
const renderQuestions = () => {
|
|
|
|
|
questionsContainerEl.empty();
|
|
|
|
|
|
|
|
|
|
$.each(questionnaireData.questions, function(questionIndex, question) {
|
|
|
|
|
renderQuestion(questionsContainerEl, question, questionIndex);
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Отображение одного вопроса
|
|
|
|
|
const renderQuestion = (container, question, questionIndex) => {
|
|
|
|
|
const $questionEl = $('<div>', {
|
|
|
|
|
class: 'question-item',
|
|
|
|
|
'data-index': questionIndex
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const $questionTitle = $('<h3>', {
|
|
|
|
|
text: question.text,
|
|
|
|
|
class: 'question-title'
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (question.required) {
|
|
|
|
|
const $requiredMark = $('<span>', {
|
|
|
|
|
class: 'required-mark',
|
|
|
|
|
text: ' *'
|
|
|
|
|
});
|
|
|
|
|
$questionTitle.append($requiredMark);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$questionEl.append($questionTitle);
|
|
|
|
|
|
|
|
|
|
// Отображение вариантов ответа в зависимости от типа вопроса
|
|
|
|
|
switch (question.type) {
|
|
|
|
|
case 'single_choice':
|
|
|
|
|
renderSingleChoice($questionEl, question, questionIndex);
|
|
|
|
|
break;
|
|
|
|
|
case 'multiple_choice':
|
|
|
|
|
renderMultipleChoice($questionEl, question, questionIndex);
|
|
|
|
|
break;
|
|
|
|
|
case 'text':
|
|
|
|
|
renderTextAnswer($questionEl, question, questionIndex);
|
|
|
|
|
break;
|
|
|
|
|
case 'scale':
|
|
|
|
|
renderScale($questionEl, question, questionIndex);
|
|
|
|
|
break;
|
|
|
|
|
case 'rating': // Обратная совместимость для рейтинга
|
|
|
|
|
renderScale($questionEl, { ...question, scaleMax: 5 }, questionIndex);
|
|
|
|
|
break;
|
|
|
|
|
case 'tag_cloud':
|
|
|
|
|
renderTagCloud($questionEl, question, questionIndex);
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
container.append($questionEl);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Отображение вопроса с одиночным выбором
|
|
|
|
|
const renderSingleChoice = ($container, question, questionIndex) => {
|
|
|
|
|
const $optionsContainer = $('<div>', {
|
|
|
|
|
class: 'radio-options-container'
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
$.each(question.options, function(optionIndex, option) {
|
|
|
|
|
const $optionContainer = $('<div>', {
|
|
|
|
|
class: 'radio-option'
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const $optionInput = $('<input>', {
|
|
|
|
|
type: 'radio',
|
|
|
|
|
id: `question_${questionIndex}_option_${optionIndex}`,
|
|
|
|
|
name: `question_${questionIndex}`,
|
|
|
|
|
value: optionIndex,
|
|
|
|
|
required: question.required
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const $optionLabel = $('<label>', {
|
|
|
|
|
for: `question_${questionIndex}_option_${optionIndex}`,
|
|
|
|
|
text: option.text
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
$optionContainer.append($optionInput, $optionLabel);
|
|
|
|
|
$optionsContainer.append($optionContainer);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
$container.append($optionsContainer);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Отображение вопроса с множественным выбором
|
|
|
|
|
const renderMultipleChoice = ($container, question, questionIndex) => {
|
|
|
|
|
const $optionsContainer = $('<div>', {
|
|
|
|
|
class: 'checkbox-options-container'
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Скрытое поле для проверки заполнения обязательного вопроса с чекбоксами
|
|
|
|
|
if (question.required) {
|
|
|
|
|
const $validationCheckbox = $('<input>', {
|
|
|
|
|
type: 'checkbox',
|
|
|
|
|
class: 'multiple-choice-validation',
|
|
|
|
|
required: true,
|
|
|
|
|
name: `question_${questionIndex}_validation`,
|
|
|
|
|
style: 'display: none;',
|
|
|
|
|
id: `question_${questionIndex}_validation`
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
$optionsContainer.append($validationCheckbox);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$.each(question.options, function(optionIndex, option) {
|
|
|
|
|
const $optionContainer = $('<div>', {
|
|
|
|
|
class: 'checkbox-option'
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const $optionInput = $('<input>', {
|
|
|
|
|
type: 'checkbox',
|
|
|
|
|
id: `question_${questionIndex}_option_${optionIndex}`,
|
|
|
|
|
name: `question_${questionIndex}_option_${optionIndex}`,
|
|
|
|
|
value: 'true'
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const $optionLabel = $('<label>', {
|
|
|
|
|
for: `question_${questionIndex}_option_${optionIndex}`,
|
|
|
|
|
text: option.text
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Обработчик изменения значения чекбокса
|
|
|
|
|
if (question.required) {
|
|
|
|
|
$optionInput.on('change', function() {
|
|
|
|
|
const anyChecked = $optionsContainer.find('input[type="checkbox"]:checked:not(.multiple-choice-validation)').length > 0;
|
|
|
|
|
$(`#question_${questionIndex}_validation`).prop('checked', anyChecked);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$optionContainer.append($optionInput, $optionLabel);
|
|
|
|
|
$optionsContainer.append($optionContainer);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
$container.append($optionsContainer);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Отображение текстового вопроса
|
|
|
|
|
const renderTextAnswer = ($container, question, questionIndex) => {
|
|
|
|
|
const $textareaContainer = $('<div>', {
|
|
|
|
|
class: 'textarea-container'
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const $textarea = $('<textarea>', {
|
|
|
|
|
id: `question_${questionIndex}_text`,
|
|
|
|
|
name: `question_${questionIndex}_text`,
|
|
|
|
|
rows: 4,
|
|
|
|
|
placeholder: 'Введите ваш ответ здесь...',
|
|
|
|
|
required: question.required
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
$textareaContainer.append($textarea);
|
|
|
|
|
$container.append($textareaContainer);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Отображение вопроса с шкалой оценки
|
|
|
|
|
const renderScale = ($container, question, questionIndex) => {
|
|
|
|
|
const minValue = question.scaleMin || 0;
|
|
|
|
|
const maxValue = question.scaleMax || 10;
|
|
|
|
|
const minLabel = question.scaleMinLabel || 'Минимум';
|
|
|
|
|
const maxLabel = question.scaleMaxLabel || 'Максимум';
|
|
|
|
|
|
|
|
|
|
const $scaleContainer = $('<div>', {
|
|
|
|
|
class: 'scale-container'
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const $scaleLabels = $('<div>', {
|
|
|
|
|
class: 'scale-labels'
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
$scaleLabels.append(
|
|
|
|
|
$('<span>', { class: 'scale-label-min', text: minLabel }),
|
|
|
|
|
$('<span>', { class: 'scale-label-max', text: maxLabel })
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const $scaleValues = $('<div>', {
|
|
|
|
|
class: 'scale-values'
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Создаем кнопки для шкалы от min до max
|
|
|
|
|
for (let i = minValue; i <= maxValue; i++) {
|
|
|
|
|
const $scaleItem = $('<div>', {
|
|
|
|
|
class: 'scale-item'
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const $scaleInput = $('<input>', {
|
|
|
|
|
type: 'radio',
|
|
|
|
|
id: `question_${questionIndex}_scale_${i}`,
|
|
|
|
|
name: `question_${questionIndex}`,
|
|
|
|
|
value: i,
|
|
|
|
|
required: question.required
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const $scaleLabel = $('<label>', {
|
|
|
|
|
for: `question_${questionIndex}_scale_${i}`,
|
|
|
|
|
text: i
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
$scaleItem.append($scaleInput, $scaleLabel);
|
|
|
|
|
$scaleValues.append($scaleItem);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$scaleContainer.append($scaleLabels, $scaleValues);
|
|
|
|
|
$container.append($scaleContainer);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Отображение вопроса с облаком тегов
|
|
|
|
|
const renderTagCloud = ($container, question, questionIndex) => {
|
|
|
|
|
const $tagCloudContainer = $('<div>', {
|
|
|
|
|
class: 'tag-cloud-container'
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (question.options && question.options.length > 0) {
|
|
|
|
|
// Предопределенное облако тегов
|
|
|
|
|
$.each(question.options, function(optionIndex, option) {
|
|
|
|
|
if (!option || !option.text) return; // Пропускаем пустые или невалидные опции
|
|
|
|
|
|
|
|
|
|
const $tagItem = $('<div>', {
|
|
|
|
|
class: 'tag-item',
|
|
|
|
|
text: option.text,
|
|
|
|
|
'data-index': optionIndex,
|
|
|
|
|
click: function() {
|
|
|
|
|
$(this).toggleClass('selected');
|
|
|
|
|
updateTagValidation();
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
$tagCloudContainer.append($tagItem);
|
|
|
|
|
});
|
|
|
|
|
} else {
|
|
|
|
|
// Пользовательское облако тегов
|
|
|
|
|
const $tagInputContainer = $('<div>', {
|
|
|
|
|
class: 'tag-input-container'
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const $tagInput = $('<input>', {
|
|
|
|
|
type: 'text',
|
2025-03-12 00:09:36 +03:00
|
|
|
|
placeholder: 'Введите тег и нажмите Enter',
|
2025-03-11 23:50:50 +03:00
|
|
|
|
class: 'tag-input'
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const $tagItems = $('<div>', {
|
|
|
|
|
class: 'tag-items'
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Обработчик ввода тегов
|
|
|
|
|
$tagInput.on('keypress', function(e) {
|
|
|
|
|
if (e.which === 13) { // Enter
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
|
|
|
|
|
const inputText = $(this).val().trim();
|
|
|
|
|
if (inputText) {
|
2025-03-12 00:09:36 +03:00
|
|
|
|
// Добавляем тег как отдельный элемент
|
|
|
|
|
const $tagItem = $('<div>', {
|
|
|
|
|
class: 'tag-item selected',
|
|
|
|
|
text: inputText
|
|
|
|
|
});
|
2025-03-11 23:50:50 +03:00
|
|
|
|
|
2025-03-12 00:09:36 +03:00
|
|
|
|
const $tagRemove = $('<span>', {
|
|
|
|
|
class: 'tag-remove',
|
|
|
|
|
html: '×',
|
|
|
|
|
click: function(e) {
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
$(this).parent().remove();
|
|
|
|
|
updateTagValidation();
|
2025-03-11 23:50:50 +03:00
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2025-03-12 00:09:36 +03:00
|
|
|
|
$tagItem.append($tagRemove);
|
|
|
|
|
$tagItems.append($tagItem);
|
|
|
|
|
|
2025-03-11 23:50:50 +03:00
|
|
|
|
$(this).val('');
|
|
|
|
|
updateTagValidation();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
$tagInputContainer.append($tagInput, $tagItems);
|
|
|
|
|
$tagCloudContainer.append($tagInputContainer);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Скрытое поле для валидации
|
|
|
|
|
if (question.required) {
|
|
|
|
|
const $validationInput = $('<input>', {
|
|
|
|
|
type: 'text',
|
|
|
|
|
required: true,
|
|
|
|
|
style: 'display: none;',
|
|
|
|
|
id: `question_${questionIndex}_tag_validation`
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
$tagCloudContainer.append($validationInput);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$container.append($tagCloudContainer);
|
|
|
|
|
|
|
|
|
|
// Функция для обновления валидации тегов
|
|
|
|
|
function updateTagValidation() {
|
|
|
|
|
if (question.required) {
|
|
|
|
|
const hasSelectedTags = $tagCloudContainer.find('.tag-item.selected').length > 0;
|
|
|
|
|
$(`#question_${questionIndex}_tag_validation`).val(hasSelectedTags ? 'valid' : '');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2025-03-12 00:09:36 +03:00
|
|
|
|
// Создание анимации конфети
|
|
|
|
|
const createConfetti = () => {
|
|
|
|
|
// Создаем контейнер для конфети, если его еще нет
|
|
|
|
|
let $confettiContainer = $('.confetti-container');
|
|
|
|
|
if ($confettiContainer.length === 0) {
|
|
|
|
|
$confettiContainer = $('<div>', { class: 'confetti-container' });
|
|
|
|
|
$('body').append($confettiContainer);
|
|
|
|
|
} else {
|
|
|
|
|
$confettiContainer.empty(); // Очищаем предыдущие конфети
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Параметры конфети
|
|
|
|
|
const confettiCount = 100; // Количество конфети
|
|
|
|
|
const shapes = ['square', 'circle', 'triangle']; // Формы конфети
|
|
|
|
|
const colors = ['red', 'blue', 'green', 'yellow', 'purple']; // Цвета конфети
|
|
|
|
|
|
|
|
|
|
// Создаем конфети
|
|
|
|
|
for (let i = 0; i < confettiCount; i++) {
|
|
|
|
|
// Случайные параметры для конфети
|
|
|
|
|
const shape = shapes[Math.floor(Math.random() * shapes.length)];
|
|
|
|
|
const color = colors[Math.floor(Math.random() * colors.length)];
|
|
|
|
|
const left = Math.random() * 100; // Позиция по горизонтали (%)
|
|
|
|
|
const size = Math.random() * 10 + 5; // Размер (px)
|
|
|
|
|
|
|
|
|
|
// Случайные параметры для анимации
|
|
|
|
|
const fallDuration = Math.random() * 3 + 2; // Длительность падения (s)
|
|
|
|
|
const swayDuration = Math.random() * 2 + 1; // Длительность качания (s)
|
|
|
|
|
const fallDelay = Math.random() * 2; // Задержка начала падения (s)
|
|
|
|
|
const swayDelay = Math.random() * 1; // Задержка начала качания (s)
|
|
|
|
|
|
|
|
|
|
// Создаем элемент конфети
|
|
|
|
|
const $confetti = $('<div>', {
|
|
|
|
|
class: `confetti ${shape} ${color}`,
|
|
|
|
|
css: {
|
|
|
|
|
left: `${left}%`,
|
|
|
|
|
width: `${size}px`,
|
|
|
|
|
height: `${size}px`,
|
|
|
|
|
animation: `confetti-fall ${fallDuration}s linear ${fallDelay}s forwards, confetti-sway ${swayDuration}s ease-in-out ${swayDelay}s infinite`
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Если это треугольник, корректируем размер
|
|
|
|
|
if (shape === 'triangle') {
|
|
|
|
|
$confetti.css({
|
|
|
|
|
'border-width': `0 ${size/2}px ${size*0.87}px ${size/2}px`
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Добавляем конфети в контейнер
|
|
|
|
|
$confettiContainer.append($confetti);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Удаляем контейнер после завершения анимации
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
$confettiContainer.fadeOut(1000, function() {
|
|
|
|
|
$(this).remove();
|
|
|
|
|
});
|
|
|
|
|
}, 5000);
|
|
|
|
|
};
|
|
|
|
|
|
2025-03-11 23:50:50 +03:00
|
|
|
|
// Показываем анимацию благодарности
|
|
|
|
|
const showThankYouAnimation = () => {
|
|
|
|
|
// Скрываем форму
|
|
|
|
|
formEl.hide();
|
|
|
|
|
|
2025-03-12 00:09:36 +03:00
|
|
|
|
// Создаем анимацию конфети
|
|
|
|
|
createConfetti();
|
|
|
|
|
|
2025-03-11 23:50:50 +03:00
|
|
|
|
// Создаем анимацию благодарности
|
|
|
|
|
const $thankYouEl = $('<div>', {
|
|
|
|
|
class: 'thank-you-animation',
|
|
|
|
|
css: {
|
|
|
|
|
opacity: 0,
|
|
|
|
|
transform: 'scale(0.9)'
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const $thankYouIcon = $('<div>', {
|
|
|
|
|
class: 'thank-you-icon',
|
|
|
|
|
html: '<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" fill="currentColor" viewBox="0 0 16 16"><path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14zm0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16z"/><path d="M10.97 4.97a.235.235 0 0 0-.02.022L7.477 9.417 5.384 7.323a.75.75 0 0 0-1.06 1.06L6.97 11.03a.75.75 0 0 0 1.079-.02l3.992-4.99a.75.75 0 0 0-1.071-1.05z"/></svg>'
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const $thankYouTitle = $('<h2>', {
|
|
|
|
|
text: 'Спасибо за участие!',
|
|
|
|
|
class: 'thank-you-title'
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const $thankYouDescription = $('<p>', {
|
|
|
|
|
text: 'Ваши ответы были успешно записаны. Мы ценим ваше мнение!',
|
|
|
|
|
class: 'thank-you-description'
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
$thankYouEl.append($thankYouIcon, $thankYouTitle, $thankYouDescription);
|
|
|
|
|
|
|
|
|
|
// Добавляем анимацию перед результатами
|
|
|
|
|
resultsContainerEl.prepend($thankYouEl);
|
|
|
|
|
|
|
|
|
|
// Показываем контейнер с результатами
|
|
|
|
|
resultsContainerEl.show();
|
|
|
|
|
|
|
|
|
|
// Анимируем появление
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
$thankYouEl.css({
|
|
|
|
|
opacity: 1,
|
|
|
|
|
transform: 'scale(1)'
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Загружаем результаты через некоторое время
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
loadPollResults();
|
|
|
|
|
}, 1000);
|
|
|
|
|
}, 100);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Загрузка результатов опроса
|
|
|
|
|
const loadPollResults = () => {
|
|
|
|
|
$.ajax({
|
|
|
|
|
url: `${getApiPath()}/results/${publicLink}`,
|
|
|
|
|
method: 'GET',
|
|
|
|
|
success: function(result) {
|
|
|
|
|
if (result.success) {
|
|
|
|
|
renderPollResults(result.data);
|
|
|
|
|
} else {
|
|
|
|
|
pollResultsContainerEl.html(`<p class="error">Ошибка: ${result.error}</p>`);
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
error: function(error) {
|
|
|
|
|
console.error('Error loading poll results:', error);
|
|
|
|
|
pollResultsContainerEl.html('<p class="error">Не удалось загрузить результаты. Пожалуйста, попробуйте позже.</p>');
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Отображение результатов опроса
|
|
|
|
|
const renderPollResults = (data) => {
|
|
|
|
|
pollResultsContainerEl.empty();
|
|
|
|
|
|
|
|
|
|
// Добавляем заголовок с анимацией
|
|
|
|
|
const $resultsTitle = $('<h2>', {
|
|
|
|
|
text: 'Результаты опроса',
|
|
|
|
|
class: 'results-title'
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
pollResultsContainerEl.append($resultsTitle);
|
|
|
|
|
|
2025-03-12 00:09:36 +03:00
|
|
|
|
// Проверяем наличие вопросов в данных
|
|
|
|
|
if (!data.questions || data.questions.length === 0) {
|
|
|
|
|
pollResultsContainerEl.append($('<p>', {
|
|
|
|
|
text: 'Нет данных для отображения',
|
|
|
|
|
class: 'no-results'
|
|
|
|
|
}));
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2025-03-11 23:50:50 +03:00
|
|
|
|
// Отображаем результаты с отложенной анимацией
|
|
|
|
|
data.questions.forEach((question, index) => {
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
const $questionResult = $('<div>', {
|
|
|
|
|
class: 'question-result',
|
|
|
|
|
css: { opacity: 0, transform: 'translateY(20px)' }
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const $questionTitle = $('<h3>', {
|
|
|
|
|
text: question.text,
|
|
|
|
|
class: 'question-title'
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
$questionResult.append($questionTitle);
|
|
|
|
|
|
2025-03-12 00:09:36 +03:00
|
|
|
|
// Нормализация типа вопроса
|
|
|
|
|
const questionType = normalizeQuestionType(question.type);
|
|
|
|
|
|
2025-03-11 23:50:50 +03:00
|
|
|
|
// Отображаем результаты в зависимости от типа вопроса
|
2025-03-12 00:09:36 +03:00
|
|
|
|
switch (questionType) {
|
|
|
|
|
case 'single':
|
|
|
|
|
case 'multiple':
|
|
|
|
|
renderChoiceResults(question, $questionResult);
|
2025-03-11 23:50:50 +03:00
|
|
|
|
break;
|
2025-03-12 00:09:36 +03:00
|
|
|
|
case 'tagcloud':
|
|
|
|
|
renderTagCloudResults(question, $questionResult);
|
2025-03-11 23:50:50 +03:00
|
|
|
|
break;
|
|
|
|
|
case 'scale':
|
|
|
|
|
case 'rating':
|
2025-03-12 00:09:36 +03:00
|
|
|
|
renderScaleResults(question, $questionResult);
|
2025-03-11 23:50:50 +03:00
|
|
|
|
break;
|
|
|
|
|
case 'text':
|
2025-03-12 00:09:36 +03:00
|
|
|
|
renderTextResults(question, $questionResult);
|
2025-03-11 23:50:50 +03:00
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pollResultsContainerEl.append($questionResult);
|
|
|
|
|
|
|
|
|
|
// Анимируем появление блока результатов
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
$questionResult.css({
|
|
|
|
|
opacity: 1,
|
|
|
|
|
transform: 'translateY(0)'
|
|
|
|
|
});
|
2025-03-12 00:09:36 +03:00
|
|
|
|
}, index * 300); // Задержка между вопросами
|
|
|
|
|
});
|
2025-03-11 23:50:50 +03:00
|
|
|
|
});
|
2025-03-12 00:09:36 +03:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Нормализация типа вопроса
|
|
|
|
|
const normalizeQuestionType = (type) => {
|
|
|
|
|
const typeMap = {
|
|
|
|
|
'single_choice': 'single',
|
|
|
|
|
'multiple_choice': 'multiple',
|
|
|
|
|
'tag_cloud': 'tagcloud',
|
|
|
|
|
'single': 'single',
|
|
|
|
|
'multiple': 'multiple',
|
|
|
|
|
'tagcloud': 'tagcloud',
|
|
|
|
|
'scale': 'scale',
|
|
|
|
|
'rating': 'rating',
|
|
|
|
|
'text': 'text'
|
|
|
|
|
};
|
|
|
|
|
return typeMap[type] || type;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Отображение результатов для вопросов с выбором
|
|
|
|
|
const renderChoiceResults = (question, $container) => {
|
|
|
|
|
// Адаптируем формат опций
|
|
|
|
|
const options = question.options || [];
|
|
|
|
|
|
|
|
|
|
// Находим общее количество голосов
|
|
|
|
|
const totalVotes = options.reduce((sum, option) => sum + (option.count || option.votes || 0), 0);
|
|
|
|
|
|
|
|
|
|
if (totalVotes > 0) {
|
|
|
|
|
options.forEach((option) => {
|
|
|
|
|
const votes = option.count || option.votes || 0;
|
|
|
|
|
const percent = totalVotes > 0 ? Math.round(votes * 100 / totalVotes) : 0;
|
|
|
|
|
|
|
|
|
|
const $optionResult = $('<div>', {
|
|
|
|
|
class: 'result-bar-container'
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const $optionLabel = $('<div>', {
|
|
|
|
|
class: 'result-label',
|
|
|
|
|
text: option.text
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const $resultBar = $('<div>', {
|
|
|
|
|
class: 'result-bar'
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const $resultBarFill = $('<div>', {
|
|
|
|
|
class: 'result-bar-fill',
|
|
|
|
|
css: { width: '0%' }
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const $resultPercent = $('<div>', {
|
|
|
|
|
class: 'result-percent',
|
|
|
|
|
text: `${percent}% (${votes} голосов)`
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
$resultBar.append($resultBarFill);
|
|
|
|
|
$optionResult.append($optionLabel, $resultBar, $resultPercent);
|
|
|
|
|
$container.append($optionResult);
|
|
|
|
|
|
|
|
|
|
// Анимируем заполнение полосы после добавления в DOM
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
$resultBarFill.css('width', `${percent}%`);
|
|
|
|
|
}, 100);
|
|
|
|
|
});
|
|
|
|
|
} else {
|
|
|
|
|
$container.append($('<p>', {
|
|
|
|
|
text: 'Пока нет голосов для этого вопроса',
|
|
|
|
|
class: 'no-results'
|
|
|
|
|
}));
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Отображение результатов для облака тегов
|
|
|
|
|
const renderTagCloudResults = (question, $container) => {
|
|
|
|
|
// Используем правильное поле для тегов
|
|
|
|
|
const tags = question.tags || [];
|
|
|
|
|
|
|
|
|
|
if (tags.length > 0 && tags.some(tag => (tag.count || 0) > 0)) {
|
|
|
|
|
const $tagCloud = $('<div>', {
|
|
|
|
|
class: 'results-tag-cloud'
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Находим максимальное количество для масштабирования
|
|
|
|
|
const maxCount = Math.max(...tags.map(tag => tag.count || 1));
|
|
|
|
|
|
|
|
|
|
tags.forEach((tag, tagIndex) => {
|
|
|
|
|
if (!tag.text) return; // Пропускаем некорректные теги
|
|
|
|
|
|
|
|
|
|
// Масштабируем размер тега от 1 до 3 в зависимости от частоты
|
|
|
|
|
const scale = maxCount > 1 ? 1 + ((tag.count || 1) / maxCount) * 2 : 1;
|
|
|
|
|
|
|
|
|
|
const $tag = $('<div>', {
|
|
|
|
|
class: 'result-tag',
|
|
|
|
|
text: tag.text,
|
|
|
|
|
css: {
|
|
|
|
|
opacity: 0,
|
|
|
|
|
transform: 'scale(0.5)',
|
|
|
|
|
fontSize: `${scale}em`
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
$tagCloud.append($tag);
|
|
|
|
|
|
|
|
|
|
// Анимируем появление тега с задержкой
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
$tag.css({
|
|
|
|
|
opacity: 1,
|
|
|
|
|
transform: 'scale(1)'
|
|
|
|
|
});
|
|
|
|
|
}, tagIndex * 100);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
$container.append($tagCloud);
|
|
|
|
|
} else {
|
|
|
|
|
$container.append($('<p>', {
|
|
|
|
|
text: 'Пока нет тегов для этого вопроса',
|
|
|
|
|
class: 'no-results'
|
|
|
|
|
}));
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Отображение результатов для шкалы и рейтинга
|
|
|
|
|
const renderScaleResults = (question, $container) => {
|
|
|
|
|
// Используем доступное поле для значений шкалы
|
|
|
|
|
const values = question.responses && question.responses.length > 0
|
|
|
|
|
? question.responses
|
|
|
|
|
: (question.scaleValues || []);
|
|
|
|
|
|
|
|
|
|
if (values.length > 0) {
|
|
|
|
|
// Используем уже рассчитанное среднее или рассчитываем сами
|
|
|
|
|
const average = question.scaleAverage !== undefined
|
|
|
|
|
? question.scaleAverage
|
|
|
|
|
: (values.reduce((sum, val) => sum + val, 0) / values.length);
|
|
|
|
|
|
|
|
|
|
const min = Math.min(...values);
|
|
|
|
|
const max = Math.max(...values);
|
|
|
|
|
|
|
|
|
|
const $scaleAverage = $('<div>', {
|
|
|
|
|
class: 'scale-average',
|
|
|
|
|
html: `Средняя оценка: <span class="highlight">${average.toFixed(1)}</span> (мин: ${min}, макс: ${max})`
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
$container.append($scaleAverage);
|
|
|
|
|
} else {
|
|
|
|
|
$container.append($('<p>', {
|
|
|
|
|
text: 'Пока нет оценок для этого вопроса',
|
|
|
|
|
class: 'no-results'
|
|
|
|
|
}));
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Отображение результатов для текстовых вопросов
|
|
|
|
|
const renderTextResults = (question, $container) => {
|
|
|
|
|
// Используем доступное поле для текстовых ответов
|
|
|
|
|
const answers = question.textAnswers && question.textAnswers.length > 0
|
|
|
|
|
? question.textAnswers
|
|
|
|
|
: (question.answers || []);
|
|
|
|
|
|
|
|
|
|
if (answers.length > 0) {
|
|
|
|
|
const $answersContainer = $('<div>', {
|
|
|
|
|
class: 'text-answers-container'
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
answers.forEach((answer, answerIndex) => {
|
|
|
|
|
const $textAnswer = $('<div>', {
|
|
|
|
|
class: 'text-answer',
|
|
|
|
|
text: answer,
|
|
|
|
|
css: {
|
|
|
|
|
opacity: 0,
|
|
|
|
|
transform: 'translateX(20px)'
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
$answersContainer.append($textAnswer);
|
|
|
|
|
|
|
|
|
|
// Анимируем появление ответа с задержкой
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
$textAnswer.css({
|
|
|
|
|
opacity: 1,
|
|
|
|
|
transform: 'translateX(0)'
|
|
|
|
|
});
|
|
|
|
|
}, answerIndex * 200);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
$container.append($answersContainer);
|
|
|
|
|
} else {
|
|
|
|
|
$container.append($('<p>', {
|
|
|
|
|
text: 'Пока нет текстовых ответов для этого вопроса',
|
2025-03-11 23:50:50 +03:00
|
|
|
|
class: 'no-results'
|
|
|
|
|
}));
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Обработка отправки формы
|
|
|
|
|
formEl.on('submit', function(e) {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
|
|
|
|
|
// Всегда используем навигацию через кнопки в пошаговом режиме
|
|
|
|
|
return false;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Инициализация
|
|
|
|
|
loadQuestionnaire();
|
|
|
|
|
});
|