/** * 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 = `
${voteCount} ${this.constructor.pluralize(voteCount, 'проголосовал', 'проголосовало', 'проголосовало')}
`; } 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 = `
${this.escapeHtml(poll.question)}
`; poll.options.forEach(option => { const isVoted = userVotedOptionIds.includes(option.id); const percentage = option.percentage || 0; const voteCount = option.vote_count || 0; const voters = option.voters || []; // Показываем результаты если голосование закрыто или пользователь проголосовал const showResults = isClosed || hasVoted; // Формируем HTML списка проголосовавших let votersHtml = ''; if (!poll.is_anonymous && voters.length > 0) { votersHtml = `
${voteCount} ${this.pluralize(voteCount, 'проголосовал', 'проголосовало', 'проголосовало')}
`; } else if (!poll.is_anonymous && voteCount > 0) { votersHtml = `
${voteCount} ${this.pluralize(voteCount, 'голос', 'голоса', 'голосов')}
`; } html += `
${showResults ? `
${isVoted ? '' : ''} ${this.escapeHtml(option.text)} ${percentage}%
${votersHtml}
` : ` `}
`; }); 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;