multy-stub/server/routers/questioneer/public/static/js/poll.js
2025-03-12 00:37:32 +03:00

1202 lines
43 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* 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;
// Объявляем переменную для хранения накопленных ответов
let accumulatedAnswers = [];
// Проверка доступности 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 = () => {
// Проверяем, содержит ли путь /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 {
// Для локальной разработки: формируем путь к API без учета текущей страницы
// Извлекаем базовый путь из URL страницы до /poll
const basePath = pathname.split('/poll')[0];
// Путь до API приложения
return basePath + '/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" class="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;
}
// Сохраняем ответ на текущий вопрос
saveCurrentAnswer(currentQuestion, currentQuestionIndex);
// Если есть еще вопросы, переходим к следующему
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 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('Не удалось отправить ответы. Пожалуйста, попробуйте позже.', 'Ошибка');
}
});
};
// Проверка наличия ответа на вопрос
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 $tagItem = $('<div>', {
class: 'tag-item selected',
text: inputText
});
const $tagRemove = $('<span>', {
class: 'tag-remove',
html: '&times;',
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 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);
};
// Показываем анимацию благодарности
const showThankYouAnimation = () => {
// Скрываем форму
formEl.hide();
// Создаем анимацию конфети
createConfetti();
// Создаем анимацию благодарности
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);
// Проверяем наличие вопросов в данных
if (!data.questions || data.questions.length === 0) {
pollResultsContainerEl.append($('<p>', {
text: 'Нет данных для отображения',
class: 'no-results'
}));
return;
}
// Отображаем результаты с отложенной анимацией
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);
// Нормализация типа вопроса
const questionType = normalizeQuestionType(question.type);
// Отображаем результаты в зависимости от типа вопроса
switch (questionType) {
case 'single':
case 'multiple':
renderChoiceResults(question, $questionResult);
break;
case 'tagcloud':
renderTagCloudResults(question, $questionResult);
break;
case 'scale':
case 'rating':
renderScaleResults(question, $questionResult);
break;
case 'text':
renderTextResults(question, $questionResult);
break;
}
pollResultsContainerEl.append($questionResult);
// Анимируем появление блока результатов
setTimeout(() => {
$questionResult.css({
opacity: 1,
transform: 'translateY(0)'
});
}, index * 300); // Задержка между вопросами
});
});
};
// Нормализация типа вопроса
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: 'Пока нет текстовых ответов для этого вопроса',
class: 'no-results'
}));
}
};
// Обработка отправки формы
formEl.on('submit', function(e) {
e.preventDefault();
// Всегда используем навигацию через кнопки в пошаговом режиме
return false;
});
// Инициализация
loadQuestionnaire();
});