/**
* Chat Poll Module
* Управление голосованиями в чате
*/
export class ChatPoll {
constructor(options = {}) {
this.chatId = options.chatId;
this.createUrl = options.createUrl || '/api/v1/communications/polls/';
this.voteUrl = options.voteUrl || '/api/v1/communications/polls/{poll_id}/vote/';
this.closeUrl = options.closeUrl || '/api/v1/communications/polls/{poll_id}/close/';
this.resultsUrl = options.resultsUrl || '/api/v1/communications/polls/{poll_id}/results/';
this.modal = document.getElementById('pollModal');
this.modalInstance = null;
if (this.modal && typeof bootstrap !== 'undefined') {
this.modalInstance = new bootstrap.Modal(this.modal);
}
this.bindEvents();
this.initializeExistingPolls();
}
initializeExistingPolls() {
const pollWidgets = document.querySelectorAll('.poll-widget[data-poll-id]');
console.log('[ChatPoll] initializeExistingPolls: found %d widgets', pollWidgets.length);
if (!pollWidgets.length) return;
pollWidgets.forEach((widget) => {
const pollId = widget.dataset.pollId;
console.log('[ChatPoll] Initializing poll widget, poll_id=%s', pollId);
if (!pollId) {
return;
}
this.refreshPoll(pollId, widget);
});
}
async refreshPoll(pollId, pollWidget = null) {
console.log('[ChatPoll] refreshPoll called, poll_id=%s', pollId);
const widget = pollWidget || document.querySelector(`[data-poll-id="${pollId}"]`);
if (!widget) {
console.warn('[ChatPoll] Widget not found for poll_id=%s', pollId);
return;
}
// Загружаем актуальные данные с сервера
const results = await this.fetchPollResults(pollId);
console.log('[ChatPoll] refreshPoll: fetched results for poll_id=%s:', pollId, results);
if (!results) return;
// Обновляем UI опроса (проценты, счётчики, кнопки)
this.updatePollWidget(widget, results);
}
async fetchPollResults(pollId) {
const url = this.resultsUrl.replace('{poll_id}', pollId);
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to fetch poll results (${response.status})`);
}
const data = await response.json();
console.log('[ChatPoll] Fetched poll results for poll_id=%s:', pollId, data);
console.log('[ChatPoll] user_voted_option_ids:', data.user_voted_option_ids);
return data;
} catch (error) {
console.error('Error fetching poll results:', error);
return null;
}
}
bindEvents() {
// Кнопка "Создать голосование"
const createBtn = document.getElementById('createPoll');
if (createBtn) {
createBtn.addEventListener('click', (e) => {
e.preventDefault();
this.openModal();
});
}
// Добавить вариант ответа
const addOptionBtn = document.getElementById('addPollOption');
if (addOptionBtn) {
addOptionBtn.addEventListener('click', () => this.addOption());
}
// Викторина - показать выбор правильного ответа
const quizCheckbox = document.getElementById('pollQuiz');
if (quizCheckbox) {
quizCheckbox.addEventListener('change', (e) => {
const correctAnswerDiv = document.getElementById('quizCorrectAnswer');
if (correctAnswerDiv) {
correctAnswerDiv.style.display = e.target.checked ? 'block' : 'none';
}
});
}
// Отправка голосования
const submitBtn = document.getElementById('submitPoll');
if (submitBtn) {
submitBtn.addEventListener('click', () => this.submitPoll());
}
// Обновление списка правильных ответов при изменении вариантов
const pollOptionsContainer = document.getElementById('pollOptions');
if (pollOptionsContainer) {
pollOptionsContainer.addEventListener('input', () => {
this.updateCorrectAnswerOptions();
});
}
// Делегирование событий для кнопок голосования (динамически добавленные)
document.addEventListener('click', (e) => {
const pollOptionBtn = e.target.closest('.btn-poll-option');
if (pollOptionBtn) {
e.preventDefault();
const optionId = parseInt(pollOptionBtn.dataset.optionId, 10);
const pollWidget = pollOptionBtn.closest('.poll-widget');
if (pollWidget) {
const pollId = parseInt(pollWidget.dataset.pollId, 10);
this.handlePollVote(pollId, optionId, pollWidget);
}
}
// Показать/скрыть список проголосовавших
const votersToggle = e.target.closest('.poll-voters-toggle');
if (votersToggle) {
e.preventDefault();
const votersList = votersToggle.nextElementSibling;
if (votersList && votersList.classList.contains('poll-voters-list')) {
const isVisible = votersList.style.display !== 'none';
votersList.style.display = isVisible ? 'none' : 'block';
const icon = votersToggle.querySelector('i');
if (icon) {
icon.className = isVisible ? 'bi-chevron-down' : 'bi-chevron-up';
}
}
}
});
}
async handlePollVote(pollId, optionId, pollWidget) {
// Проверяем не закрыто ли голосование
const footerText = pollWidget.querySelector('.poll-footer .text-muted')?.textContent || '';
if (footerText.includes('Закрыто')) {
alert('Голосование закрыто');
return;
}
console.log('[ChatPoll] Voting: poll_id=%s, option_id=%s', pollId, optionId);
// Блокируем кнопки на время голосования
const optionButtons = pollWidget.querySelectorAll('.btn-poll-option');
optionButtons.forEach(btn => {
btn.disabled = true;
btn.classList.add('disabled');
});
// Показываем индикатор загрузки
const clickedButton = pollWidget.querySelector(`[data-option-id="${optionId}"]`);
if (clickedButton) {
const originalText = clickedButton.textContent;
clickedButton.innerHTML = 'Отправка...';
clickedButton.dataset.originalText = originalText;
}
// Отправляем голос (НЕ обновляем UI сразу!)
const results = await this.vote(pollId, [optionId]);
if (!results) {
// Если ошибка - разблокируем кнопки
console.error('[ChatPoll] Vote failed, restoring buttons');
optionButtons.forEach(btn => {
btn.disabled = false;
btn.classList.remove('disabled');
});
if (clickedButton && clickedButton.dataset.originalText) {
clickedButton.textContent = clickedButton.dataset.originalText;
}
} else {
console.log('[ChatPoll] Vote sent successfully, waiting for WebSocket update...');
}
// UI обновится автоматически через WebSocket событие 'chat:poll-update'
// которое обработает chat-detail-enhanced.js → chatPoll.updatePollWidget()
}
updatePollWidget(pollWidget, results) {
if (!pollWidget) return;
console.log('[ChatPoll] Updating poll widget, poll_id=%s', results.id);
console.log('[ChatPoll] Results:', results);
console.log('[ChatPoll] Results.options:', results.options);
this.updatePollFooter(pollWidget, results);
const userVotes = Array.isArray(results.user_voted_option_ids)
? results.user_voted_option_ids
: [];
// Явное преобразование is_closed в boolean (может прийти как строка "true"/"false")
const isClosed = results.is_closed === true || results.is_closed === 'true' || results.is_closed === 'True';
const shouldShowResults = userVotes.length > 0 || isClosed;
console.log('[ChatPoll] User votes:', userVotes, 'shouldShowResults:', shouldShowResults);
console.log('[ChatPoll] is_closed:', results.is_closed, '(type: %s)', typeof results.is_closed, 'isClosed:', isClosed, 'userVotes.length:', userVotes.length);
const optionsContainer = pollWidget.querySelector('.poll-options');
if (!optionsContainer) return;
optionsContainer.innerHTML = '';
if (!shouldShowResults) {
// Показываем кнопки для голосования
results.options.forEach(option => {
const optionDiv = document.createElement('div');
optionDiv.className = 'poll-option mb-2';
optionDiv.dataset.optionId = option.id;
optionDiv.innerHTML = `
`;
optionsContainer.appendChild(optionDiv);
});
return;
}
// Показываем результаты с прогресс-барами
results.options.forEach(option => {
const isVoted = userVotes.includes(option.id);
const percentage = option.percentage || 0;
const voteCount = option.vote_count || 0;
const voters = option.voters || [];
console.log('[ChatPoll] Rendering option:', {
id: option.id,
text: option.text,
percentage,
voteCount,
isVoted,
votersCount: voters.length
});
const optionDiv = document.createElement('div');
optionDiv.className = `poll-option mb-2 ${isVoted ? 'voted' : ''}`;
optionDiv.dataset.optionId = option.id;
// Формируем HTML списка проголосовавших
let votersHtml = '';
if (!results.is_anonymous && voters.length > 0) {
votersHtml = `
`;
} else if (!results.is_anonymous && voteCount > 0) {
votersHtml = `${voteCount} ${this.constructor.pluralize(voteCount, 'голос', 'голоса', 'голосов')}
`;
}
optionDiv.innerHTML = `
${isVoted ? '' : ''}
${this.constructor.escapeHtml(option.text)}
${percentage}%
${votersHtml}
`;
optionsContainer.appendChild(optionDiv);
});
}
updatePollFooter(pollWidget, results) {
const footer = pollWidget.querySelector('.poll-footer .text-muted');
if (!footer) return;
const totalVoters = results.total_voters || 0;
footer.innerHTML = `
${totalVoters} ${this.constructor.pluralize(totalVoters, 'проголосовал', 'проголосовало', 'проголосовало')}
${results.is_anonymous ? ' • Анонимное' : ''}
${results.is_multiple_choice ? ' • Множественный выбор' : ''}
${results.is_closed ? ' • Закрыто' : ''}
`;
}
openModal() {
if (!this.modalInstance) {
console.error('Poll modal not initialized');
return;
}
// Сброс формы
this.resetForm();
this.modalInstance.show();
}
resetForm() {
// Очистить вопрос
const questionInput = document.getElementById('pollQuestion');
if (questionInput) {
questionInput.value = '';
}
// Сбросить варианты ответа
const optionsContainer = document.getElementById('pollOptions');
if (optionsContainer) {
optionsContainer.innerHTML = `
1
2
`;
}
// Сбросить чекбоксы
['pollAnonymous', 'pollMultiple', 'pollQuiz'].forEach(id => {
const checkbox = document.getElementById(id);
if (checkbox) {
checkbox.checked = false;
}
});
// Скрыть выбор правильного ответа
const correctAnswerDiv = document.getElementById('quizCorrectAnswer');
if (correctAnswerDiv) {
correctAnswerDiv.style.display = 'none';
}
// Очистить время закрытия
const closesInput = document.getElementById('pollCloses');
if (closesInput) {
closesInput.value = '';
}
}
addOption() {
const optionsContainer = document.getElementById('pollOptions');
if (!optionsContainer) return;
const currentCount = optionsContainer.querySelectorAll('input.poll-option').length;
if (currentCount >= 10) {
alert('Максимум 10 вариантов ответа');
return;
}
const newOption = document.createElement('div');
newOption.className = 'input-group mb-2';
newOption.innerHTML = `
${currentCount + 1}
`;
optionsContainer.appendChild(newOption);
// Показать кнопки удаления если больше 2 вариантов
if (currentCount + 1 > 2) {
optionsContainer.querySelectorAll('.remove-option').forEach(btn => {
btn.style.display = 'inline-block';
btn.onclick = (e) => this.removeOption(e);
});
}
this.updateCorrectAnswerOptions();
}
removeOption(event) {
const optionsContainer = document.getElementById('pollOptions');
if (!optionsContainer) return;
const optionGroup = event.target.closest('.input-group');
if (!optionGroup) return;
optionGroup.remove();
// Обновить нумерацию
const options = optionsContainer.querySelectorAll('.input-group');
options.forEach((group, index) => {
const numberSpan = group.querySelector('.input-group-text');
if (numberSpan) {
numberSpan.textContent = index + 1;
}
});
// Скрыть кнопки удаления если осталось 2 варианта
if (options.length <= 2) {
optionsContainer.querySelectorAll('.remove-option').forEach(btn => {
btn.style.display = 'none';
});
}
this.updateCorrectAnswerOptions();
}
updateCorrectAnswerOptions() {
const correctSelect = document.getElementById('pollCorrectOption');
if (!correctSelect) return;
// Ищем только input элементы внутри формы создания голосования
const optionsContainer = document.getElementById('pollOptions');
if (!optionsContainer) return;
const options = optionsContainer.querySelectorAll('input.poll-option');
correctSelect.innerHTML = '';
options.forEach((input, index) => {
const text = input.value?.trim() || '';
if (text) {
const option = document.createElement('option');
option.value = index;
option.textContent = `${index + 1}. ${text.substring(0, 50)}${text.length > 50 ? '...' : ''}`;
correctSelect.appendChild(option);
}
});
}
async submitPoll() {
// Валидация
const questionInput = document.getElementById('pollQuestion');
const question = questionInput?.value?.trim() || '';
if (!question) {
alert('Введите вопрос');
questionInput?.focus();
return;
}
// Собрать варианты ответов - ищем только в форме создания
const optionsContainer = document.getElementById('pollOptions');
if (!optionsContainer) {
console.error('Poll options container not found');
return;
}
const optionInputs = optionsContainer.querySelectorAll('input.poll-option');
const options = Array.from(optionInputs)
.map(input => input.value?.trim() || '')
.filter(text => text.length > 0);
if (options.length < 2) {
alert('Необходимо минимум 2 варианта ответа');
return;
}
// Настройки
const isAnonymous = document.getElementById('pollAnonymous')?.checked || false;
const isMultiple = document.getElementById('pollMultiple')?.checked || false;
const isQuiz = document.getElementById('pollQuiz')?.checked || false;
let correctOptionIndex = null;
if (isQuiz) {
const correctSelect = document.getElementById('pollCorrectOption');
correctOptionIndex = correctSelect?.value ? parseInt(correctSelect.value, 10) : null;
if (correctOptionIndex === null || isNaN(correctOptionIndex)) {
alert('Для викторины выберите правильный ответ');
return;
}
}
const closesInMinutes = parseInt(document.getElementById('pollCloses')?.value, 10) || null;
// Отправка
const submitBtn = document.getElementById('submitPoll');
if (submitBtn) {
submitBtn.disabled = true;
submitBtn.innerHTML = 'Создание...';
}
try {
const response = await fetch(this.createUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': this.getCsrfToken()
},
body: JSON.stringify({
chat_id: this.chatId,
question,
options_data: options, // Исправлено: options_data вместо options
is_anonymous: isAnonymous,
is_multiple_choice: isMultiple,
is_quiz: isQuiz,
correct_option_index: correctOptionIndex,
closes_in_minutes: closesInMinutes
})
});
const data = await response.json();
if (response.ok) {
// Закрыть модалку
if (this.modalInstance) {
this.modalInstance.hide();
}
// Сообщение о успехе
console.log('Poll created:', data);
// Голосование появится через WebSocket
} else {
alert(data.error || 'Ошибка создания голосования');
}
} catch (error) {
console.error('Error creating poll:', error);
alert('Ошибка при создании голосования');
} finally {
if (submitBtn) {
submitBtn.disabled = false;
submitBtn.innerHTML = 'Создать';
}
}
}
async vote(pollId, optionIds) {
if (!Array.isArray(optionIds)) {
optionIds = [optionIds];
}
const url = this.voteUrl.replace('{poll_id}', pollId);
try {
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': this.getCsrfToken()
},
body: JSON.stringify({
poll_id: pollId,
option_ids: optionIds
})
});
const data = await response.json();
if (response.ok) {
return data.results;
} else {
alert(data.error || 'Ошибка голосования');
return null;
}
} catch (error) {
console.error('Error voting:', error);
alert('Ошибка при голосовании');
return null;
}
}
async closePoll(pollId) {
const url = this.closeUrl.replace('{poll_id}', pollId);
try {
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': this.getCsrfToken()
},
body: JSON.stringify({ poll_id: pollId })
});
const data = await response.json();
if (response.ok) {
console.log('Poll closed');
return true;
} else {
alert(data.error || 'Ошибка закрытия голосования');
return false;
}
} catch (error) {
console.error('Error closing poll:', error);
alert('Ошибка при закрытии голосования');
return false;
}
}
getCsrfToken() {
// Сначала пробуем получить из input
const tokenEl = document.querySelector('[name=csrfmiddlewaretoken]');
if (tokenEl && tokenEl.value) {
return tokenEl.value;
}
// Если нет, получаем из cookie
const match = document.cookie.match(/csrftoken=([^;]+)/);
return match ? match[1] : '';
}
/**
* Рендер HTML для голосования
*/
static renderPoll(poll, userVotedOptionIds = []) {
const totalVotes = poll.total_voters || 0;
const isClosed = poll.is_closed;
const hasVoted = userVotedOptionIds.length > 0;
let html = `
`;
return html;
}
static escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
static pluralize(count, one, few, many) {
const mod10 = count % 10;
const mod100 = count % 100;
if (mod10 === 1 && mod100 !== 11) {
return one;
}
if (mod10 >= 2 && mod10 <= 4 && (mod100 < 10 || mod100 >= 20)) {
return few;
}
return many;
}
}
export default ChatPoll;