1069 lines
38 KiB
JavaScript
1069 lines
38 KiB
JavaScript
|
/* 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;
|
|||
|
|
|||
|
// Проверка доступности 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');
|
|||
|
};
|
|||
|
|
|||
|
// Получаем базовый путь API
|
|||
|
const getApiPath = () => {
|
|||
|
const pathParts = window.location.pathname.split('/');
|
|||
|
// Убираем последние две части пути (poll/:publicLink)
|
|||
|
pathParts.pop();
|
|||
|
pathParts.pop();
|
|||
|
return pathParts.join('/') + '/api';
|
|||
|
};
|
|||
|
|
|||
|
// Загрузка данных опроса
|
|||
|
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>
|
|||
|
<span id="question-counter">Вопрос 1 из ${questionnaireData.questions.length}</span>
|
|||
|
<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;
|
|||
|
}
|
|||
|
|
|||
|
// Если есть еще вопросы, переходим к следующему
|
|||
|
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);
|
|||
|
}
|
|||
|
};
|
|||
|
|
|||
|
// Проверка наличия ответа на вопрос
|
|||
|
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',
|
|||
|
placeholder: 'Введите теги, разделенные пробелом, и нажмите Enter',
|
|||
|
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) {
|
|||
|
// Разбиваем ввод на отдельные теги
|
|||
|
const tags = inputText.split(/\s+/);
|
|||
|
|
|||
|
// Добавляем каждый тег как отдельный элемент
|
|||
|
tags.forEach(tagText => {
|
|||
|
if (tagText) {
|
|||
|
const $tagItem = $('<div>', {
|
|||
|
class: 'tag-item selected',
|
|||
|
text: tagText
|
|||
|
});
|
|||
|
|
|||
|
const $tagRemove = $('<span>', {
|
|||
|
class: 'tag-remove',
|
|||
|
html: '×',
|
|||
|
click: function(e) {
|
|||
|
e.stopPropagation();
|
|||
|
$(this).parent().remove();
|
|||
|
updateTagValidation();
|
|||
|
}
|
|||
|
});
|
|||
|
|
|||
|
$tagItem.append($tagRemove);
|
|||
|
$tagItems.append($tagItem);
|
|||
|
}
|
|||
|
});
|
|||
|
|
|||
|
$(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' : '');
|
|||
|
}
|
|||
|
}
|
|||
|
};
|
|||
|
|
|||
|
// Показываем анимацию благодарности
|
|||
|
const showThankYouAnimation = () => {
|
|||
|
// Скрываем форму
|
|||
|
formEl.hide();
|
|||
|
|
|||
|
// Создаем анимацию благодарности
|
|||
|
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 submitForm = function() {
|
|||
|
// Отключаем атрибут required у невидимых полей перед отправкой
|
|||
|
$('input[required]:not(:visible), textarea[required]:not(:visible)').prop('required', false);
|
|||
|
|
|||
|
// Собираем ответы
|
|||
|
const answers = [];
|
|||
|
|
|||
|
$.each(questionnaireData.questions, function(questionIndex, question) {
|
|||
|
const $questionEl = $(`.question-item[data-index="${questionIndex}"]`);
|
|||
|
|
|||
|
let optionIndices;
|
|||
|
let selectedOption;
|
|||
|
let selectedOptions;
|
|||
|
let selectedTags;
|
|||
|
let textAnswer;
|
|||
|
let scaleValue;
|
|||
|
|
|||
|
switch (question.type) {
|
|||
|
case 'single_choice':
|
|||
|
selectedOption = $questionEl.find(`input[name="question_${questionIndex}"]:checked`);
|
|||
|
if (selectedOption.length) {
|
|||
|
answers.push({
|
|||
|
questionIndex,
|
|||
|
optionIndices: [parseInt(selectedOption.val())]
|
|||
|
});
|
|||
|
}
|
|||
|
break;
|
|||
|
|
|||
|
case 'multiple_choice':
|
|||
|
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]);
|
|||
|
});
|
|||
|
answers.push({
|
|||
|
questionIndex,
|
|||
|
optionIndices
|
|||
|
});
|
|||
|
}
|
|||
|
break;
|
|||
|
|
|||
|
case 'text':
|
|||
|
textAnswer = $questionEl.find('textarea').val().trim();
|
|||
|
if (textAnswer) {
|
|||
|
answers.push({
|
|||
|
questionIndex,
|
|||
|
textAnswer
|
|||
|
});
|
|||
|
}
|
|||
|
break;
|
|||
|
|
|||
|
case 'scale':
|
|||
|
case 'rating': // Обратная совместимость
|
|||
|
selectedOption = $questionEl.find(`input[name="question_${questionIndex}"]:checked`);
|
|||
|
if (selectedOption.length) {
|
|||
|
answers.push({
|
|||
|
questionIndex,
|
|||
|
scaleValue: parseInt(selectedOption.val())
|
|||
|
});
|
|||
|
}
|
|||
|
break;
|
|||
|
|
|||
|
case 'tag_cloud':
|
|||
|
selectedTags = $questionEl.find('.tag-item.selected');
|
|||
|
if (selectedTags.length) {
|
|||
|
const tagTexts = $.map(selectedTags, function(tag) {
|
|||
|
return $(tag).text().replace('×', '').trim();
|
|||
|
});
|
|||
|
answers.push({
|
|||
|
questionIndex,
|
|||
|
tagTexts
|
|||
|
});
|
|||
|
}
|
|||
|
break;
|
|||
|
}
|
|||
|
});
|
|||
|
|
|||
|
// Отправляем ответы на сервер
|
|||
|
$.ajax({
|
|||
|
url: `${getApiPath()}/vote/${publicLink}`,
|
|||
|
method: 'POST',
|
|||
|
contentType: 'application/json',
|
|||
|
data: JSON.stringify({ answers }),
|
|||
|
success: function(result) {
|
|||
|
if (result.success) {
|
|||
|
// Сохраняем информацию о прохождении опроса
|
|||
|
markAsCompleted();
|
|||
|
|
|||
|
// Показываем анимацию благодарности вместо сразу скрытия формы
|
|||
|
showThankYouAnimation();
|
|||
|
} else {
|
|||
|
showAlert(`Ошибка: ${result.error}`, 'Ошибка');
|
|||
|
}
|
|||
|
},
|
|||
|
error: function(error) {
|
|||
|
console.error('Error submitting poll:', error);
|
|||
|
showAlert('Не удалось отправить ответы. Пожалуйста, попробуйте позже.', 'Ошибка');
|
|||
|
}
|
|||
|
});
|
|||
|
};
|
|||
|
|
|||
|
// Загрузка результатов опроса
|
|||
|
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);
|
|||
|
|
|||
|
// Отображаем результаты с отложенной анимацией
|
|||
|
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);
|
|||
|
|
|||
|
// Отображаем результаты в зависимости от типа вопроса
|
|||
|
switch (question.type) {
|
|||
|
case 'single_choice':
|
|||
|
case 'multiple_choice':
|
|||
|
if (question.options && question.options.length > 0) {
|
|||
|
// Находим общее количество голосов
|
|||
|
const totalVotes = question.options.reduce((sum, option) => sum + (option.count || 0), 0);
|
|||
|
|
|||
|
if (totalVotes > 0) {
|
|||
|
question.options.forEach((option) => {
|
|||
|
const percent = totalVotes > 0 ? Math.round((option.count || 0) * 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}% (${option.count || 0} голосов)`
|
|||
|
});
|
|||
|
|
|||
|
$resultBar.append($resultBarFill);
|
|||
|
$optionResult.append($optionLabel, $resultBar, $resultPercent);
|
|||
|
$questionResult.append($optionResult);
|
|||
|
|
|||
|
// Анимируем заполнение полосы после добавления в DOM
|
|||
|
setTimeout(() => {
|
|||
|
$resultBarFill.css('width', `${percent}%`);
|
|||
|
}, 100);
|
|||
|
});
|
|||
|
} else {
|
|||
|
$questionResult.append($('<p>', {
|
|||
|
text: 'Пока нет голосов для этого вопроса',
|
|||
|
class: 'no-results'
|
|||
|
}));
|
|||
|
}
|
|||
|
}
|
|||
|
break;
|
|||
|
|
|||
|
case 'tag_cloud':
|
|||
|
if (question.tags && question.tags.length > 0) {
|
|||
|
const $tagCloud = $('<div>', {
|
|||
|
class: 'results-tag-cloud'
|
|||
|
});
|
|||
|
|
|||
|
// Находим максимальное количество для масштабирования
|
|||
|
const maxCount = Math.max(...question.tags.map(tag => tag.count || 1));
|
|||
|
|
|||
|
question.tags.forEach((tag, tagIndex) => {
|
|||
|
// Масштабируем размер тега от 1 до 3 в зависимости от частоты
|
|||
|
const scale = maxCount > 1 ? 1 + (tag.count / 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);
|
|||
|
});
|
|||
|
|
|||
|
$questionResult.append($tagCloud);
|
|||
|
} else {
|
|||
|
$questionResult.append($('<p>', {
|
|||
|
text: 'Пока нет тегов для этого вопроса',
|
|||
|
class: 'no-results'
|
|||
|
}));
|
|||
|
}
|
|||
|
break;
|
|||
|
|
|||
|
case 'scale':
|
|||
|
case 'rating':
|
|||
|
if (question.scaleValues && question.scaleValues.length > 0) {
|
|||
|
const average = question.scaleAverage ||
|
|||
|
(question.scaleValues.reduce((sum, val) => sum + val, 0) / question.scaleValues.length);
|
|||
|
|
|||
|
const min = Math.min(...question.scaleValues);
|
|||
|
const max = Math.max(...question.scaleValues);
|
|||
|
|
|||
|
const $scaleAverage = $('<div>', {
|
|||
|
class: 'scale-average',
|
|||
|
html: `Средняя оценка: <span class="highlight">${average.toFixed(1)}</span> (мин: ${min}, макс: ${max})`
|
|||
|
});
|
|||
|
|
|||
|
$questionResult.append($scaleAverage);
|
|||
|
} else {
|
|||
|
$questionResult.append($('<p>', {
|
|||
|
text: 'Пока нет оценок для этого вопроса',
|
|||
|
class: 'no-results'
|
|||
|
}));
|
|||
|
}
|
|||
|
break;
|
|||
|
|
|||
|
case 'text':
|
|||
|
if (question.answers && question.answers.length > 0) {
|
|||
|
const $answersContainer = $('<div>', {
|
|||
|
class: 'text-answers-container'
|
|||
|
});
|
|||
|
|
|||
|
question.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);
|
|||
|
});
|
|||
|
|
|||
|
$questionResult.append($answersContainer);
|
|||
|
} else {
|
|||
|
$questionResult.append($('<p>', {
|
|||
|
text: 'Пока нет текстовых ответов для этого вопроса',
|
|||
|
class: 'no-results'
|
|||
|
}));
|
|||
|
}
|
|||
|
break;
|
|||
|
}
|
|||
|
|
|||
|
pollResultsContainerEl.append($questionResult);
|
|||
|
|
|||
|
// Анимируем появление блока результатов
|
|||
|
setTimeout(() => {
|
|||
|
$questionResult.css({
|
|||
|
opacity: 1,
|
|||
|
transform: 'translateY(0)'
|
|||
|
});
|
|||
|
}, 100);
|
|||
|
}, index * 300); // Задержка между вопросами
|
|||
|
});
|
|||
|
|
|||
|
// Если нет данных для отображения
|
|||
|
if (!data.questions || data.questions.length === 0) {
|
|||
|
pollResultsContainerEl.append($('<p>', {
|
|||
|
text: 'Нет данных для отображения',
|
|||
|
class: 'no-results'
|
|||
|
}));
|
|||
|
}
|
|||
|
};
|
|||
|
|
|||
|
// Обработка отправки формы
|
|||
|
formEl.on('submit', function(e) {
|
|||
|
e.preventDefault();
|
|||
|
|
|||
|
// Всегда используем навигацию через кнопки в пошаговом режиме
|
|||
|
return false;
|
|||
|
});
|
|||
|
|
|||
|
// Инициализация
|
|||
|
loadQuestionnaire();
|
|||
|
});
|