multy-stub/server/routers/questioneer/public/static/js/poll.js

1202 lines
43 KiB
JavaScript
Raw Normal View History

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 {
// Для локальной разработки: формируем путь к API без учета текущей страницы
// Извлекаем базовый путь из URL страницы до /poll
const basePath = pathname.split('/poll')[0];
2025-03-12 00:09:36 +03:00
// Путь до API приложения
return basePath + '/api';
2025-03-12 00:09:36 +03:00
}
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: '&times;',
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();
});