fix путей

This commit is contained in:
Primakov Alexandr Alexandrovich 2025-03-12 00:09:36 +03:00
parent 1fcc5ed70d
commit dd589790c2
12 changed files with 1997 additions and 529 deletions

View File

@ -0,0 +1,583 @@
openapi: 3.0.0
info:
title: Анонимные опросы API
description: API для работы с системой анонимных опросов
version: 1.0.0
servers:
- url: /questioneer/api
description: Базовый URL API
paths:
/questionnaires:
get:
summary: Получить список опросов пользователя
description: Возвращает список всех опросов, сохраненных в локальном хранилище браузера
operationId: getQuestionnaires
responses:
'200':
description: Успешный запрос
content:
application/json:
schema:
$ref: '#/components/schemas/QuestionnairesResponse'
post:
summary: Создать новый опрос
description: Создает новый опрос с указанными параметрами
operationId: createQuestionnaire
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/QuestionnaireCreate'
responses:
'200':
description: Опрос успешно создан
content:
application/json:
schema:
$ref: '#/components/schemas/QuestionnaireResponse'
/questionnaires/public/{publicLink}:
get:
summary: Получить опрос для участия
description: Возвращает данные опроса по публичной ссылке
operationId: getPublicQuestionnaire
parameters:
- name: publicLink
in: path
required: true
schema:
type: string
responses:
'200':
description: Успешный запрос
content:
application/json:
schema:
$ref: '#/components/schemas/QuestionnaireResponse'
'404':
description: Опрос не найден
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
/questionnaires/admin/{adminLink}:
get:
summary: Получить опрос для редактирования и просмотра результатов
description: Возвращает данные опроса по административной ссылке
operationId: getAdminQuestionnaire
parameters:
- name: adminLink
in: path
required: true
schema:
type: string
responses:
'200':
description: Успешный запрос
content:
application/json:
schema:
$ref: '#/components/schemas/QuestionnaireResponse'
'404':
description: Опрос не найден
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
put:
summary: Обновить опрос
description: Обновляет существующий опрос
operationId: updateQuestionnaire
parameters:
- name: adminLink
in: path
required: true
schema:
type: string
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/QuestionnaireUpdate'
responses:
'200':
description: Опрос успешно обновлен
content:
application/json:
schema:
$ref: '#/components/schemas/QuestionnaireResponse'
'404':
description: Опрос не найден
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
delete:
summary: Удалить опрос
description: Удаляет опрос вместе со всеми ответами
operationId: deleteQuestionnaire
parameters:
- name: adminLink
in: path
required: true
schema:
type: string
responses:
'200':
description: Опрос успешно удален
content:
application/json:
schema:
$ref: '#/components/schemas/SuccessResponse'
'404':
description: Опрос не найден
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
/vote/{publicLink}:
post:
summary: Отправить ответы на опрос
description: Отправляет ответы пользователя на опрос
operationId: submitVote
parameters:
- name: publicLink
in: path
required: true
schema:
type: string
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/VoteRequest'
responses:
'200':
description: Ответы успешно отправлены
content:
application/json:
schema:
$ref: '#/components/schemas/SuccessResponse'
'404':
description: Опрос не найден
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
/results/{publicLink}:
get:
summary: Получить результаты опроса
description: Возвращает текущие результаты опроса
operationId: getResults
parameters:
- name: publicLink
in: path
required: true
schema:
type: string
responses:
'200':
description: Успешный запрос
content:
application/json:
schema:
$ref: '#/components/schemas/ResultsResponse'
'404':
description: Опрос не найден
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
components:
schemas:
QuestionnaireCreate:
type: object
required:
- title
- questions
properties:
title:
type: string
description: Название опроса
description:
type: string
description: Описание опроса
questions:
type: array
description: Список вопросов
items:
$ref: '#/components/schemas/Question'
displayType:
type: string
description: Тип отображения опроса
enum: [standard, step_by_step]
default: standard
QuestionnaireUpdate:
type: object
properties:
title:
type: string
description: Название опроса
description:
type: string
description: Описание опроса
questions:
type: array
description: Список вопросов
items:
$ref: '#/components/schemas/Question'
displayType:
type: string
description: Тип отображения опроса
enum: [standard, step_by_step]
Question:
type: object
required:
- text
- type
properties:
text:
type: string
description: Текст вопроса
type:
type: string
description: Тип вопроса
enum: [single, multiple, text, scale, rating, tagcloud]
required:
type: boolean
description: Является ли вопрос обязательным
default: false
options:
type: array
description: Варианты ответа (для single, multiple)
items:
$ref: '#/components/schemas/Option'
tags:
type: array
description: Список тегов (для tagcloud)
items:
$ref: '#/components/schemas/Tag'
scaleMin:
type: integer
description: Минимальное значение шкалы (для scale)
default: 0
scaleMax:
type: integer
description: Максимальное значение шкалы (для scale)
default: 10
scaleMinLabel:
type: string
description: Метка для минимального значения шкалы
default: "Минимум"
scaleMaxLabel:
type: string
description: Метка для максимального значения шкалы
default: "Максимум"
Option:
type: object
required:
- text
properties:
text:
type: string
description: Текст варианта ответа
votes:
type: integer
description: Количество голосов за этот вариант
default: 0
Tag:
type: object
required:
- text
properties:
text:
type: string
description: Текст тега
count:
type: integer
description: Количество выборов данного тега
default: 0
VoteRequest:
type: object
required:
- answers
properties:
answers:
type: array
description: Список ответов пользователя
items:
$ref: '#/components/schemas/Answer'
Answer:
type: object
required:
- questionIndex
properties:
questionIndex:
type: integer
description: Индекс вопроса
optionIndices:
type: array
description: Индексы выбранных вариантов (для single, multiple)
items:
type: integer
textAnswer:
type: string
description: Текстовый ответ пользователя (для text)
scaleValue:
type: integer
description: Значение шкалы (для scale, rating)
tagTexts:
type: array
description: Тексты выбранных или введенных тегов (для tagcloud)
items:
type: string
QuestionnairesResponse:
type: object
properties:
success:
type: boolean
description: Успешность запроса
data:
type: array
description: Список опросов
items:
$ref: '#/components/schemas/QuestionnaireInfo'
QuestionnaireResponse:
type: object
properties:
success:
type: boolean
description: Успешность запроса
data:
$ref: '#/components/schemas/QuestionnaireData'
QuestionnaireInfo:
type: object
properties:
title:
type: string
description: Название опроса
description:
type: string
description: Описание опроса
adminLink:
type: string
description: Административная ссылка
publicLink:
type: string
description: Публичная ссылка
createdAt:
type: string
format: date-time
description: Дата создания опроса
updatedAt:
type: string
format: date-time
description: Дата последнего обновления опроса
QuestionnaireData:
type: object
properties:
_id:
type: string
description: Идентификатор опроса
title:
type: string
description: Название опроса
description:
type: string
description: Описание опроса
questions:
type: array
description: Список вопросов
items:
$ref: '#/components/schemas/QuestionData'
displayType:
type: string
description: Тип отображения опроса
enum: [standard, step_by_step]
adminLink:
type: string
description: Административная ссылка
publicLink:
type: string
description: Публичная ссылка
createdAt:
type: string
format: date-time
description: Дата создания опроса
updatedAt:
type: string
format: date-time
description: Дата последнего обновления опроса
QuestionData:
type: object
properties:
_id:
type: string
description: Идентификатор вопроса
text:
type: string
description: Текст вопроса
type:
type: string
description: Тип вопроса
required:
type: boolean
description: Является ли вопрос обязательным
options:
type: array
description: Варианты ответа (для single, multiple)
items:
$ref: '#/components/schemas/OptionData'
tags:
type: array
description: Список тегов (для tagcloud)
items:
$ref: '#/components/schemas/TagData'
scaleMin:
type: integer
description: Минимальное значение шкалы (для scale)
scaleMax:
type: integer
description: Максимальное значение шкалы (для scale)
scaleMinLabel:
type: string
description: Метка для минимального значения шкалы
scaleMaxLabel:
type: string
description: Метка для максимального значения шкалы
answers:
type: array
description: Текстовые ответы (для text)
items:
type: string
scaleValues:
type: array
description: Значения шкалы от пользователей (для scale, rating)
items:
type: integer
textAnswers:
type: array
description: Текстовые ответы (для text)
items:
type: string
responses:
type: array
description: Значения шкалы от пользователей (для scale, rating)
items:
type: integer
OptionData:
type: object
properties:
_id:
type: string
description: Идентификатор варианта ответа
text:
type: string
description: Текст варианта ответа
votes:
type: integer
description: Количество голосов за этот вариант
count:
type: integer
description: Альтернативное поле для количества голосов
TagData:
type: object
properties:
_id:
type: string
description: Идентификатор тега
text:
type: string
description: Текст тега
count:
type: integer
description: Количество выборов данного тега
ResultsResponse:
type: object
properties:
success:
type: boolean
description: Успешность запроса
data:
$ref: '#/components/schemas/ResultsData'
ResultsData:
type: object
properties:
questions:
type: array
description: Список вопросов с результатами
items:
$ref: '#/components/schemas/QuestionResults'
QuestionResults:
type: object
properties:
text:
type: string
description: Текст вопроса
type:
type: string
description: Тип вопроса
options:
type: array
description: Варианты ответа с количеством голосов (для single, multiple)
items:
type: object
properties:
text:
type: string
description: Текст варианта ответа
count:
type: integer
description: Количество голосов
tags:
type: array
description: Список тегов с количеством выборов (для tagcloud)
items:
type: object
properties:
text:
type: string
description: Текст тега
count:
type: integer
description: Количество выборов
scaleValues:
type: array
description: Значения шкалы от пользователей (для scale, rating)
items:
type: integer
scaleAverage:
type: number
description: Среднее значение шкалы (для scale, rating)
answers:
type: array
description: Текстовые ответы (для text)
items:
type: string
responses:
type: array
description: Значения шкалы от пользователей (для scale, rating)
items:
type: integer
SuccessResponse:
type: object
properties:
success:
type: boolean
description: Успешность запроса
example: true
message:
type: string
description: Сообщение об успешном выполнении
ErrorResponse:
type: object
properties:
success:
type: boolean
description: Успешность запроса
example: false
error:
type: string
description: Сообщение об ошибке

View File

@ -4,7 +4,45 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Управление опросом</title>
<link rel="stylesheet" href="/questioneer/static/css/style.css">
<!-- Добавляем проверку на различные пути -->
<script>
// Определяем путь к статическим файлам с учетом prod и dev окружений
function getStaticPath() {
if (window.location.pathname.includes('/ms/questioneer')) {
// Для продакшна
return '/ms/questioneer/static';
} else {
// Для локальной разработки
return window.location.pathname.split('/admin')[0] + '/static';
}
}
// Динамически добавляем CSS
const cssLink = document.createElement('link');
cssLink.rel = 'stylesheet';
cssLink.href = getStaticPath() + '/css/style.css';
document.head.appendChild(cssLink);
</script>
<!-- Добавляем jQuery -->
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/qrcode-generator/1.4.4/qrcode.min.js"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
// Динамически добавляем скрипты
const scriptPaths = [
'/js/common.js',
'/js/admin.js'
];
const staticPath = getStaticPath();
scriptPaths.forEach(path => {
const script = document.createElement('script');
script.src = staticPath + path;
document.body.appendChild(script);
});
});
</script>
</head>
<body>
<!-- Навигационная шапка -->
@ -59,12 +97,5 @@
</div>
</div>
</div>
<script src="https://code.jquery.com/jquery-3.7.1.min.js"
integrity="sha256-/JqT3SQfawRcv/BIHPThkBvs0OEvtFFmqPF/lYI/Cxo="
crossorigin="anonymous"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/qrcode-generator/1.4.4/qrcode.min.js"></script>
<script src="/questioneer/static/js/common.js"></script>
<script src="/questioneer/static/js/admin.js"></script>
</body>
</html>

View File

@ -3,8 +3,45 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Создание нового опроса</title>
<link rel="stylesheet" href="/questioneer/static/css/style.css">
<title>Создать опрос</title>
<!-- Добавляем проверку на различные пути -->
<script>
// Определяем путь к статическим файлам с учетом prod и dev окружений
function getStaticPath() {
if (window.location.pathname.includes('/ms/questioneer')) {
// Для продакшна
return '/ms/questioneer/static';
} else {
// Для локальной разработки
return window.location.pathname.split('/create')[0] + '/static';
}
}
// Динамически добавляем CSS
const cssLink = document.createElement('link');
cssLink.rel = 'stylesheet';
cssLink.href = getStaticPath() + '/css/style.css';
document.head.appendChild(cssLink);
</script>
<!-- Добавляем jQuery и остальные скрипты с учетом переменного пути -->
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
// Динамически добавляем скрипты
const scriptPaths = [
'/js/common.js',
'/js/create.js'
];
const staticPath = getStaticPath();
scriptPaths.forEach(path => {
const script = document.createElement('script');
script.src = staticPath + path;
document.body.appendChild(script);
});
});
</script>
</head>
<body>
<div class="container">
@ -119,11 +156,5 @@
</button>
</div>
</template>
<script src="https://code.jquery.com/jquery-3.7.1.min.js"
integrity="sha256-/JqT3SQfawRcv/BIHPThkBvs0OEvtFFmqPF/lYI/Cxo="
crossorigin="anonymous"></script>
<script src="/questioneer/static/js/common.js"></script>
<script src="/questioneer/static/js/create.js"></script>
</body>
</html>

View File

@ -4,7 +4,45 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Редактирование опроса</title>
<link rel="stylesheet" href="/questioneer/static/css/style.css">
<!-- Добавляем проверку на различные пути -->
<script>
// Определяем путь к статическим файлам с учетом prod и dev окружений
function getStaticPath() {
if (window.location.pathname.includes('/ms/questioneer')) {
// Для продакшна
return '/ms/questioneer/static';
} else {
// Для локальной разработки
return window.location.pathname.split('/edit')[0] + '/static';
}
}
// Динамически добавляем CSS
const cssLink = document.createElement('link');
cssLink.rel = 'stylesheet';
cssLink.href = getStaticPath() + '/css/style.css';
document.head.appendChild(cssLink);
</script>
<!-- Добавляем jQuery -->
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/qrcode-generator/1.4.4/qrcode.min.js"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
// Динамически добавляем скрипты
const scriptPaths = [
'/js/common.js',
'/js/edit.js'
];
const staticPath = getStaticPath();
scriptPaths.forEach(path => {
const script = document.createElement('script');
script.src = staticPath + path;
document.body.appendChild(script);
});
});
</script>
</head>
<body>
<div class="container">
@ -135,12 +173,5 @@
</button>
</div>
</template>
<script src="https://code.jquery.com/jquery-3.7.1.min.js"
integrity="sha256-/JqT3SQfawRcv/BIHPThkBvs0OEvtFFmqPF/lYI/Cxo="
crossorigin="anonymous"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/qrcode-generator/1.4.4/qrcode.min.js"></script>
<script src="/questioneer/static/js/common.js"></script>
<script src="/questioneer/static/js/edit.js"></script>
</body>
</html>

View File

@ -4,7 +4,50 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Анонимные опросы</title>
<link rel="stylesheet" href="/questioneer/static/css/style.css">
<!-- Добавляем проверку на различные пути -->
<script>
// Определяем путь к статическим файлам с учетом prod и dev окружений
function getStaticPath() {
const pathname = window.location.pathname;
if (pathname.includes('/ms/questioneer')) {
// Для продакшна
return '/ms/questioneer/static';
} else {
// Для локальной разработки
// Если путь заканчивается на слеш или на /questioneer, добавляем /static
if (pathname.endsWith('/') || pathname.endsWith('/questioneer')) {
return pathname + 'static';
} else {
return pathname + '/static';
}
}
}
// Динамически добавляем CSS
const cssLink = document.createElement('link');
cssLink.rel = 'stylesheet';
cssLink.href = getStaticPath() + '/css/style.css';
document.head.appendChild(cssLink);
</script>
<!-- Добавляем jQuery -->
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
// Динамически добавляем скрипты
const scriptPaths = [
'/js/common.js',
'/js/index.js'
];
const staticPath = getStaticPath();
scriptPaths.forEach(path => {
const script = document.createElement('script');
script.src = staticPath + path;
document.body.appendChild(script);
});
});
</script>
</head>
<body>
<!-- Навигационная шапка -->
@ -32,11 +75,5 @@
</div>
</div>
</div>
<script src="https://code.jquery.com/jquery-3.7.1.min.js"
integrity="sha256-/JqT3SQfawRcv/BIHPThkBvs0OEvtFFmqPF/lYI/Cxo="
crossorigin="anonymous"></script>
<script src="/questioneer/static/js/common.js"></script>
<script src="/questioneer/static/js/index.js"></script>
</body>
</html>

View File

@ -4,7 +4,44 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Участие в опросе</title>
<link rel="stylesheet" href="/questioneer/static/css/style.css">
<!-- Добавляем проверку на различные пути -->
<script>
// Определяем путь к статическим файлам с учетом prod и dev окружений
function getStaticPath() {
if (window.location.pathname.includes('/ms/questioneer')) {
// Для продакшна
return '/ms/questioneer/static';
} else {
// Для локальной разработки
return window.location.pathname.split('/poll')[0] + '/static';
}
}
// Динамически добавляем CSS
const cssLink = document.createElement('link');
cssLink.rel = 'stylesheet';
cssLink.href = getStaticPath() + '/css/style.css';
document.head.appendChild(cssLink);
</script>
<!-- Добавляем jQuery -->
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
// Динамически добавляем скрипты
const scriptPaths = [
'/js/common.js',
'/js/poll.js'
];
const staticPath = getStaticPath();
scriptPaths.forEach(path => {
const script = document.createElement('script');
script.src = staticPath + path;
document.body.appendChild(script);
});
});
</script>
</head>
<body>
<div class="container">
@ -35,11 +72,5 @@
</div>
</div>
</div>
<script src="https://code.jquery.com/jquery-3.7.1.min.js"
integrity="sha256-/JqT3SQfawRcv/BIHPThkBvs0OEvtFFmqPF/lYI/Cxo="
crossorigin="anonymous"></script>
<script src="/questioneer/static/js/common.js"></script>
<script src="/questioneer/static/js/poll.js"></script>
</body>
</html>

View File

@ -66,11 +66,12 @@ p {
}
.btn-primary {
background-color: #4caf50;
background-color: #1976d2;
color: white;
}
.btn-primary:hover {
background-color: #388e3c;
background-color: #1565c0;
}
.btn-secondary {
@ -593,13 +594,14 @@ select:focus {
justify-content: space-between;
align-items: center;
margin-top: 20px;
padding-top: 15px;
border-top: 1px solid #444;
margin-bottom: 10px;
}
#question-counter {
.question-counter {
text-align: center;
font-size: 1rem;
color: #bbdefb;
color: #64b5f6;
flex: 1;
}
/* Улучшения для облака тегов */
@ -755,7 +757,7 @@ select:focus {
.scale-average .highlight {
color: var(--color-primary);
font-size: 1.5em;
animation: pulse 2s infinite;
font-weight: bold;
}
.result-bar-container {
@ -837,15 +839,9 @@ select:focus {
}
@keyframes pulse {
0% {
transform: scale(1);
}
50% {
transform: scale(1.05);
}
100% {
transform: scale(1);
}
0% { opacity: 0.8; }
50% { opacity: 1; }
100% { opacity: 0.8; }
}
.question-result {
@ -863,7 +859,7 @@ select:focus {
}
.question-counter.update {
animation: pulse 0.5s;
animation: fadeIn 0.5s;
}
@keyframes fadeIn {
@ -908,7 +904,7 @@ select:focus {
.loading-text {
margin-top: 15px;
animation: pulse 1.5s infinite;
color: #64b5f6;
}
/* Анимации */
@ -980,7 +976,8 @@ select:focus {
}
.btn-primary {
animation: pulse 2s infinite;
background-color: #1976d2;
color: white;
}
.question-item.error {
@ -1002,7 +999,9 @@ select:focus {
}
.tag-item.selected {
animation: pulse 1s;
background-color: var(--color-primary);
color: white;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
}
.scale-item label:hover {
@ -1011,7 +1010,9 @@ select:focus {
}
.scale-item input:checked + label {
animation: pulse 0.5s;
background-color: var(--color-primary);
color: white;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
}
/* Анимированный лоадер */
@ -1155,10 +1156,15 @@ input[type="checkbox"], input[type="radio"] {
.welcome-icon svg,
.thank-you-icon svg,
.completed-icon svg {
width: 80px;
height: 80px;
color: var(--color-primary, #2196f3);
opacity: 0.8;
width: 100px;
height: 100px;
color: var(--color-primary);
}
.welcome-icon svg:hover,
.thank-you-icon svg:hover,
.completed-icon svg:hover {
transform: scale(1.05);
}
.welcome-title,
@ -1180,11 +1186,19 @@ input[type="checkbox"], input[type="radio"] {
.welcome-start-btn,
.view-results-btn {
margin-top: 20px;
font-size: 1.1rem;
padding: 12px 30px;
font-size: 1.2rem;
padding: 12px 24px;
border-radius: 30px;
animation: pulse 2s infinite;
background-color: var(--color-primary);
color: white;
border: none;
cursor: pointer;
transition: background-color 0.3s;
}
.welcome-start-btn:hover,
.view-results-btn:hover {
background-color: #0d47a1;
}
.start-again-btn {
@ -1207,12 +1221,6 @@ input[type="checkbox"], input[type="radio"] {
}
}
.welcome-icon svg,
.thank-you-icon svg,
.completed-icon svg {
animation: pulse 3s infinite;
}
/* CSS переменные для цветов */
:root {
--color-primary: #2196f3;
@ -1236,9 +1244,9 @@ input[type="checkbox"], input[type="radio"] {
display: flex;
align-items: center;
justify-content: center;
padding: 10px 20px;
border-radius: 30px;
transition: all 0.3s ease;
min-width: 110px;
background-color: #1976d2;
color: white;
}
.nav-btn svg {
@ -1246,8 +1254,13 @@ input[type="checkbox"], input[type="radio"] {
}
.nav-btn:hover {
transform: translateY(-3px);
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.2);
background-color: #0d47a1;
}
.nav-btn:disabled {
background-color: #455a64;
cursor: not-allowed;
opacity: 0.7;
}
/* Стили для статистики в админке */
@ -1828,3 +1841,341 @@ input[type="checkbox"], input[type="radio"] {
white-space: pre-wrap;
word-break: break-word;
}
/* Добавляем стили для анимации конфети */
.confetti-container {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
z-index: 9999;
overflow: hidden;
}
.confetti {
position: absolute;
width: 10px;
height: 10px;
background-color: #90caf9;
border-radius: 3px;
will-change: transform;
}
.confetti.square {
border-radius: 0;
}
.confetti.triangle {
width: 0;
height: 0;
background-color: transparent;
border-style: solid;
border-width: 0 5px 8.7px 5px;
border-color: transparent transparent #90caf9 transparent;
}
.confetti.circle {
border-radius: 50%;
}
.confetti.red {
background-color: #f44336;
border-color: transparent transparent #f44336 transparent;
}
.confetti.blue {
background-color: #2196f3;
border-color: transparent transparent #2196f3 transparent;
}
.confetti.green {
background-color: #4caf50;
border-color: transparent transparent #4caf50 transparent;
}
.confetti.yellow {
background-color: #ffeb3b;
border-color: transparent transparent #ffeb3b transparent;
}
.confetti.purple {
background-color: #9c27b0;
border-color: transparent transparent #9c27b0 transparent;
}
@keyframes confetti-fall {
0% {
transform: translateY(-100vh) rotate(0deg);
}
100% {
transform: translateY(100vh) rotate(360deg);
}
}
@keyframes confetti-sway {
0% {
transform: translateX(0);
}
25% {
transform: translateX(10px);
}
50% {
transform: translateX(-10px);
}
75% {
transform: translateX(5px);
}
100% {
transform: translateX(0);
}
}
/* Медиа-запросы для адаптивности */
@media (max-width: 768px) {
/* Основные стили */
.container {
padding: 15px;
}
h1 {
font-size: 2rem;
}
h2 {
font-size: 1.75rem;
}
h3 {
font-size: 1.25rem;
}
/* Навигация */
.nav-container {
flex-direction: column;
padding: 10px;
}
.nav-menu {
margin-top: 10px;
width: 100%;
justify-content: center;
}
.nav-link {
margin: 0 10px;
}
/* Формы */
.form-group {
margin-bottom: 15px;
}
input[type="text"],
input[type="email"],
input[type="number"],
textarea,
select {
padding: 8px;
font-size: 14px;
}
/* Кнопки */
.btn {
padding: 8px 16px;
font-size: 14px;
}
.questionnaire-actions {
flex-direction: column;
}
.questionnaire-actions .btn {
width: 100%;
margin-bottom: 10px;
}
/* Опросы */
.question-item {
padding: 15px;
margin-bottom: 15px;
}
/* Навигация по опросу */
.step-navigation {
flex-direction: column;
align-items: center;
}
.step-navigation .btn {
width: 100%;
margin: 5px 0;
}
.question-counter {
margin-bottom: 10px;
width: 100%;
text-align: center;
}
/* Шкала оценки */
.scale-container {
padding: 10px;
}
.scale-values {
flex-wrap: wrap;
justify-content: center;
}
.scale-item {
margin: 5px;
}
/* Облако тегов */
.tag-cloud-container {
padding: 10px;
}
.tag-item {
margin: 5px;
font-size: 14px;
padding: 5px 10px;
}
/* Результаты опроса */
.questionnaire-links {
flex-direction: column;
}
.link-group {
width: 100%;
}
.link-input-group {
flex-direction: column;
}
.link-input-group input {
width: 100%;
margin-bottom: 10px;
}
.stats-table th,
.stats-table td {
padding: 5px;
font-size: 14px;
}
/* Модальные окна */
.modal-content {
width: 90%;
padding: 15px;
}
.modal-footer {
flex-direction: column;
}
.modal-footer .btn {
width: 100%;
margin: 5px 0;
}
/* Улучшенные стили для навигации в опросе на мобильных устройствах */
.step-navigation {
flex-direction: column-reverse;
align-items: stretch;
}
.step-navigation .btn {
margin: 5px 0;
width: 100%;
height: 44px; /* Увеличиваем высоту для лучшего тача */
}
.question-counter {
margin: 10px 0;
order: -1; /* Показываем счетчик вопросов сверху на мобильных */
}
/* Улучшения для формы */
.form-actions {
flex-direction: column;
gap: 10px;
}
.form-actions .btn {
width: 100%;
height: 44px; /* Увеличиваем высоту для лучшего тача */
}
/* Улучшения для textarea */
textarea {
min-height: 100px;
}
/* Улучшения для облака тегов */
.tag-input {
width: 100%;
height: 44px; /* Увеличиваем высоту для лучшего тача */
font-size: 16px; /* Оптимальный размер шрифта для мобильных устройств */
}
/* Убираем зум при фокусе на поля ввода */
input[type="text"],
input[type="email"],
input[type="number"],
textarea,
select {
font-size: 16px; /* Оптимальный размер шрифта для мобильных устройств */
}
}
/* Медиа-запросы для маленьких экранов */
@media (max-width: 480px) {
h1 {
font-size: 1.75rem;
}
h2 {
font-size: 1.5rem;
}
h3 {
font-size: 1.25rem;
}
.container {
padding: 10px;
}
.btn {
padding: 8px 12px;
font-size: 13px;
}
.question-item {
padding: 12px;
}
.tag-item {
margin: 3px;
font-size: 13px;
padding: 4px 8px;
}
.scale-item label {
min-width: 30px;
height: 30px;
font-size: 13px;
}
/* Уменьшаем размер чекбоксов и радиокнопок для лучшего тача */
input[type="checkbox"],
input[type="radio"] {
transform: scale(1.2);
margin-right: 8px;
}
}

View File

@ -3,13 +3,27 @@ $(document).ready(function() {
const adminLink = window.location.pathname.split('/').pop();
let questionnaireData = null;
// Получаем базовый путь API (для работы и с /questioneer, и с /ms/questioneer)
// Функция для получения базового пути API
const getApiPath = () => {
const pathParts = window.location.pathname.split('/');
// Убираем последние две части пути (admin/:adminLink)
pathParts.pop();
// Проверяем, содержит ли путь /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 {
// Для локальной разработки: используем обычный путь
// Извлекаем базовый путь из URL страницы
const pathParts = pathname.split('/');
// Если последний сегмент пустой (из-за /) - удаляем его
if (pathParts[pathParts.length - 1] === '') {
pathParts.pop();
}
// Путь до корня приложения
return pathParts.join('/') + '/api';
}
};
// Загрузка данных опроса
@ -40,7 +54,17 @@ $(document).ready(function() {
// Формируем ссылки
const baseUrl = window.location.origin;
const baseQuestionnairePath = window.location.pathname.split('/admin')[0];
const isMsPath = window.location.pathname.includes('/ms/questioneer');
let baseQuestionnairePath;
if (isMsPath) {
// Для продакшна: используем /ms/questioneer
baseQuestionnairePath = '/ms/questioneer';
} else {
// Для локальной разработки: используем текущий путь
baseQuestionnairePath = window.location.pathname.split('/admin')[0];
}
const publicUrl = `${baseUrl}${baseQuestionnairePath}/poll/${questionnaireData.publicLink}`;
const adminUrl = `${baseUrl}${baseQuestionnairePath}/admin/${questionnaireData.adminLink}`;
@ -65,23 +89,32 @@ $(document).ready(function() {
// Проверяем наличие ответов для каждого типа вопросов
for (const question of questions) {
if (question.type === 'single' || question.type === 'multiple') {
if (question.options && question.options.some(option => option.votes && option.votes > 0)) {
// Согласовываем типы вопросов между бэкендом и фронтендом
const questionType = normalizeQuestionType(question.type);
if (questionType === 'single' || questionType === 'multiple') {
if (question.options && question.options.some(option => (option.votes > 0 || option.count > 0))) {
hasAnyResponses = true;
break;
}
} else if (question.type === 'tagcloud') {
if (question.tags && question.tags.some(tag => tag.count && tag.count > 0)) {
} else if (questionType === 'tagcloud') {
if (question.tags && question.tags.some(tag => tag.count > 0)) {
hasAnyResponses = true;
break;
}
} else if (question.type === 'scale' || question.type === 'rating') {
if (question.responses && question.responses.length > 0) {
} else if (questionType === 'scale' || questionType === 'rating') {
// Проверяем оба возможных поля для данных шкалы
const hasScaleValues = question.scaleValues && question.scaleValues.length > 0;
const hasResponses = question.responses && question.responses.length > 0;
if (hasScaleValues || hasResponses) {
hasAnyResponses = true;
break;
}
} else if (question.type === 'text') {
if (question.textAnswers && question.textAnswers.length > 0) {
} else if (questionType === 'text') {
// Проверяем оба возможных поля для текстовых ответов
const hasTextAnswers = question.textAnswers && question.textAnswers.length > 0;
const hasAnswers = question.answers && question.answers.length > 0;
if (hasTextAnswers || hasAnswers) {
hasAnyResponses = true;
break;
}
@ -99,14 +132,59 @@ $(document).ready(function() {
const $questionTitle = $('<h3>', { text: `${index + 1}. ${question.text}` });
$questionStats.append($questionTitle);
// Согласовываем типы вопросов между бэкендом и фронтендом
const questionType = normalizeQuestionType(question.type);
// В зависимости от типа вопроса отображаем разную статистику
if (question.type === 'single' || question.type === 'multiple') {
if (questionType === 'single' || questionType === 'multiple') {
// Для вопросов с выбором вариантов
const totalVotes = question.options.reduce((sum, option) => sum + (option.votes || 0), 0);
renderChoiceStats(question, $questionStats);
} else if (questionType === 'tagcloud') {
// Для облака тегов
renderTagCloudStats(question, $questionStats);
} else if (questionType === 'scale' || questionType === 'rating') {
// Для шкалы и рейтинга
renderScaleStats(question, $questionStats);
} else if (questionType === 'text') {
// Для текстовых ответов
renderTextStats(question, $questionStats);
}
$statsContainer.append($questionStats);
});
};
// Приводит тип вопроса к стандартному формату
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 renderChoiceStats = (question, $container) => {
// Преобразуем опции к единому формату
const options = question.options.map(option => ({
text: option.text,
votes: option.votes || option.count || 0
}));
const totalVotes = options.reduce((sum, option) => sum + option.votes, 0);
if (totalVotes === 0) {
$questionStats.append($('<div>', { class: 'no-votes', text: 'Нет голосов' }));
} else {
$container.append($('<div>', { class: 'no-votes', text: 'Нет голосов' }));
return;
}
const $table = $('<table>', { class: 'stats-table' });
const $thead = $('<thead>').append(
$('<tr>').append(
@ -119,8 +197,8 @@ $(document).ready(function() {
const $tbody = $('<tbody>');
question.options.forEach(option => {
const votes = option.votes || 0;
options.forEach(option => {
const votes = option.votes;
const percent = totalVotes > 0 ? Math.round((votes / totalVotes) * 100) : 0;
const $tr = $('<tr>').append(
@ -141,14 +219,17 @@ $(document).ready(function() {
});
$table.append($thead, $tbody);
$questionStats.append($table);
$questionStats.append($('<div>', { class: 'total-votes', text: `Всего голосов: ${totalVotes}` }));
}
} else if (question.type === 'tagcloud') {
// Для облака тегов
$container.append($table);
$container.append($('<div>', { class: 'total-votes', text: `Всего голосов: ${totalVotes}` }));
};
// Отображение статистики для облака тегов
const renderTagCloudStats = (question, $container) => {
if (!question.tags || question.tags.length === 0 || !question.tags.some(tag => tag.count > 0)) {
$questionStats.append($('<div>', { class: 'no-votes', text: 'Нет выбранных тегов' }));
} else {
$container.append($('<div>', { class: 'no-votes', text: 'Нет выбранных тегов' }));
return;
}
const $tagCloud = $('<div>', { class: 'tag-cloud-stats' });
// Находим максимальное количество для масштабирования
@ -171,22 +252,32 @@ $(document).ready(function() {
}
});
$questionStats.append($tagCloud);
$container.append($tagCloud);
};
// Отображение статистики для шкалы и рейтинга
const renderScaleStats = (question, $container) => {
// Используем scaleValues или responses, в зависимости от того, что доступно
const values = question.responses && question.responses.length > 0
? question.responses
: (question.scaleValues || []);
if (values.length === 0) {
$container.append($('<div>', { class: 'no-votes', text: 'Нет оценок' }));
return;
}
} else if (question.type === 'scale' || question.type === 'rating') {
// Для шкалы и рейтинга
if (!question.responses || question.responses.length === 0) {
$questionStats.append($('<div>', { class: 'no-votes', text: 'Нет оценок' }));
} else {
const values = question.responses;
const sum = values.reduce((a, b) => a + b, 0);
const avg = sum / values.length;
const min = Math.min(...values);
const max = Math.max(...values);
// Создаем контейнер для статистики
const $scaleStats = $('<div>', { class: 'scale-stats' });
// Добавляем сводную статистику
$scaleStats.append(
$('<div>', { class: 'stat-summary' }).append(
$('<div>', { class: 'stat-item' }).append(
$('<span>', { class: 'stat-label', text: 'Среднее значение:' }),
$('<span>', { class: 'stat-value', text: avg.toFixed(1) })
@ -203,18 +294,83 @@ $(document).ready(function() {
$('<span>', { class: 'stat-label', text: 'Количество оценок:' }),
$('<span>', { class: 'stat-value', text: values.length })
)
)
);
$questionStats.append($scaleStats);
// Создаем таблицу для визуализации распределения голосов
const $table = $('<table>', { class: 'stats-table' });
const $thead = $('<thead>').append(
$('<tr>').append(
$('<th>', { text: 'Значение' }),
$('<th>', { text: 'Голоса' }),
$('<th>', { text: '%' }),
$('<th>', { text: 'Визуализация' })
)
);
const $tbody = $('<tbody>');
// Определяем минимальное и максимальное значение шкалы из самих данных
// либо используем значения из настроек вопроса, если они есть
const scaleMin = question.scaleMin !== undefined ? question.scaleMin : min;
const scaleMax = question.scaleMax !== undefined ? question.scaleMax : max;
// Создаем счетчик для каждого возможного значения шкалы
const countByValue = {};
for (let i = scaleMin; i <= scaleMax; i++) {
countByValue[i] = 0;
}
} else if (question.type === 'text') {
// Для текстовых ответов
if (!question.textAnswers || question.textAnswers.length === 0) {
$questionStats.append($('<div>', { class: 'no-votes', text: 'Нет текстовых ответов' }));
} else {
// Подсчитываем количество голосов для каждого значения
values.forEach(value => {
if (countByValue[value] !== undefined) {
countByValue[value]++;
}
});
// Создаем строки таблицы для каждого значения шкалы
for (let value = scaleMin; value <= scaleMax; value++) {
const count = countByValue[value] || 0;
const percent = values.length > 0 ? Math.round((count / values.length) * 100) : 0;
const $tr = $('<tr>').append(
$('<td>', { text: value }),
$('<td>', { text: count }),
$('<td>', { text: `${percent}%` }),
$('<td>').append(
$('<div>', { class: 'bar-container' }).append(
$('<div>', {
class: 'bar',
css: { width: `${percent}%` }
})
)
)
);
$tbody.append($tr);
}
$table.append($thead, $tbody);
$scaleStats.append($table);
$container.append($scaleStats);
};
// Отображение статистики для текстовых ответов
const renderTextStats = (question, $container) => {
// Используем textAnswers или answers, в зависимости от того, что доступно
const answers = question.textAnswers && question.textAnswers.length > 0
? question.textAnswers
: (question.answers || []);
if (answers.length === 0) {
$container.append($('<div>', { class: 'no-votes', text: 'Нет текстовых ответов' }));
return;
}
const $textAnswers = $('<div>', { class: 'text-answers-list' });
question.textAnswers.forEach((answer, i) => {
answers.forEach((answer, i) => {
$textAnswers.append(
$('<div>', { class: 'text-answer-item' }).append(
$('<div>', { class: 'answer-number', text: `#${i + 1}` }),
@ -223,12 +379,7 @@ $(document).ready(function() {
);
});
$questionStats.append($textAnswers);
}
}
$statsContainer.append($questionStats);
});
$container.append($textAnswers);
};
// Копирование ссылок
@ -252,7 +403,18 @@ $(document).ready(function() {
// Редактирование опроса
$('#edit-questionnaire').on('click', function() {
const basePath = window.location.pathname.split('/admin')[0];
// Перенаправляем на страницу редактирования
const isMsPath = window.location.pathname.includes('/ms/questioneer');
let basePath;
if (isMsPath) {
// Для продакшна: используем /ms/questioneer
basePath = '/ms/questioneer';
} else {
// Для локальной разработки: используем текущий путь
basePath = window.location.pathname.split('/admin')[0];
}
window.location.href = `${basePath}/edit/${adminLink}`;
});
@ -268,7 +430,7 @@ $(document).ready(function() {
// Функция удаления опроса
const deleteQuestionnaire = () => {
$.ajax({
url: `${getApiPath()}/questionnaires/admin/${adminLink}`,
url: `${getApiPath()}/questionnaires/${adminLink}`,
method: 'DELETE',
success: function(result) {
if (result.success) {

View File

@ -6,12 +6,27 @@ $(document).ready(function() {
let questionCount = 0;
// Получаем базовый путь API (для работы и с /questioneer, и с /ms/questioneer)
// Функция для получения базового пути API
const getApiPath = () => {
const pathParts = window.location.pathname.split('/');
// Убираем последнюю часть пути (create)
// Проверяем, содержит ли путь /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 {
// Для локальной разработки: используем обычный путь
// Извлекаем базовый путь из URL страницы
const pathParts = pathname.split('/');
// Если последний сегмент пустой (из-за /) - удаляем его
if (pathParts[pathParts.length - 1] === '') {
pathParts.pop();
}
// Путь до корня приложения
return pathParts.join('/') + '/api';
}
};
// Добавление нового вопроса
@ -217,8 +232,18 @@ $(document).ready(function() {
data: JSON.stringify(questionnaire),
success: function(result) {
if (result.success) {
// Перенаправление на страницу администрирования опроса
const basePath = window.location.pathname.split('/create')[0];
// Перенаправляем на страницу администратора
const isMsPath = window.location.pathname.includes('/ms/questioneer');
let basePath;
if (isMsPath) {
// Для продакшна: используем /ms/questioneer
basePath = '/ms/questioneer';
} else {
// Для локальной разработки: используем текущий путь
basePath = window.location.pathname.split('/create')[0];
}
window.location.href = `${basePath}/admin/${result.data.adminLink}`;
} else {
showAlert(`Ошибка при создании опроса: ${result.error}`, 'Ошибка');

View File

@ -8,13 +8,27 @@ $(document).ready(function() {
let questionCount = 0;
let questionnaireData = null;
// Получаем базовый путь API
// Функция для получения базового пути API
const getApiPath = () => {
const pathParts = window.location.pathname.split('/');
// Убираем последние две части пути (edit/:adminLink)
pathParts.pop();
// Проверяем, содержит ли путь /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 {
// Для локальной разработки: используем обычный путь
// Извлекаем базовый путь из URL страницы
const pathParts = pathname.split('/');
// Если последний сегмент пустой (из-за /) - удаляем его
if (pathParts[pathParts.length - 1] === '') {
pathParts.pop();
}
// Путь до корня приложения
return pathParts.join('/') + '/api';
}
};
// Загрузка данных опроса
@ -312,10 +326,20 @@ $(document).ready(function() {
data: JSON.stringify(questionnaire),
success: function(result) {
if (result.success) {
showAlert('Опрос успешно обновлен', 'Успешно', function() {
const basePath = window.location.pathname.split('/edit')[0];
showAlert('Опрос успешно сохранен!', 'Успех', { autoClose: true });
// Перенаправляем на страницу администратора
const isMsPath = window.location.pathname.includes('/ms/questioneer');
let basePath;
if (isMsPath) {
// Для продакшна: используем /ms/questioneer
basePath = '/ms/questioneer';
} else {
// Для локальной разработки: используем текущий путь
basePath = window.location.pathname.split('/edit')[0];
}
window.location.href = `${basePath}/admin/${adminLink}`;
});
} else {
showAlert(`Ошибка при обновлении опроса: ${result.error}`, 'Ошибка');
}

View File

@ -2,8 +2,17 @@
$(document).ready(function() {
// Функция для получения базового пути 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 {
// Для локальной разработки: используем обычный путь
// Извлекаем базовый путь из URL страницы
const pathParts = window.location.pathname.split('/');
const pathParts = pathname.split('/');
// Если последний сегмент пустой (из-за /) - удаляем его
if (pathParts[pathParts.length - 1] === '') {
pathParts.pop();
@ -11,6 +20,7 @@ $(document).ready(function() {
// Путь до корня приложения
return pathParts.join('/') + '/api';
}
};
// Функция для загрузки списка опросов
@ -40,9 +50,18 @@ $(document).ready(function() {
}
// Получаем базовый путь (для работы и с /questioneer, и с /ms/questioneer)
const basePath = window.location.pathname.endsWith('/')
? window.location.pathname
: window.location.pathname + '/';
const basePath = (() => {
const pathname = window.location.pathname;
const isMsPath = pathname.includes('/ms/questioneer');
if (isMsPath) {
// Для продакшна: нужно использовать /ms/questioneer/ для ссылок
return '/ms/questioneer/';
} else {
// Для локальной разработки: используем текущий путь
return pathname.endsWith('/') ? pathname : pathname + '/';
}
})();
const questionnairesHTML = questionnaires.map(q => `
<div class="questionnaire-item">

View File

@ -23,6 +23,9 @@ $(document).ready(function() {
// Для пошаговых опросов
let currentQuestionIndex = 0;
// Объявляем переменную для хранения накопленных ответов
let accumulatedAnswers = [];
// Проверка доступности localStorage
const isLocalStorageAvailable = () => {
try {
@ -50,13 +53,33 @@ $(document).ready(function() {
window.localStorage.setItem(getLocalStorageKey(), 'true');
};
// Получаем базовый путь API
// Функция для получения базового пути API
const getApiPath = () => {
const pathParts = window.location.pathname.split('/');
// Убираем последние две части пути (poll/:publicLink)
// Проверяем, содержит ли путь /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 {
// Для локальной разработки: используем обычный путь
// Извлекаем базовый путь из URL страницы
const pathParts = pathname.split('/');
// Убираем slug и последний сегмент (poll/[id])
if (pathParts.length > 2) {
// Удаляем 2 последних сегмента пути (poll/[id])
pathParts.pop();
pathParts.pop();
}
// Если последний сегмент пустой (из-за /) - удаляем его
if (pathParts[pathParts.length - 1] === '') {
pathParts.pop();
}
// Путь до корня приложения
return pathParts.join('/') + '/api';
}
};
// Загрузка данных опроса
@ -231,7 +254,7 @@ $(document).ready(function() {
</svg>
Назад
</button>
<span id="question-counter">Вопрос 1 из ${questionnaireData.questions.length}</span>
<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">
@ -317,6 +340,9 @@ $(document).ready(function() {
return;
}
// Сохраняем ответ на текущий вопрос
saveCurrentAnswer(currentQuestion, currentQuestionIndex);
// Если есть еще вопросы, переходим к следующему
if (currentQuestionIndex < questionnaireData.questions.length - 1) {
// Анимируем текущий вопрос перед переходом к следующему
@ -343,6 +369,114 @@ $(document).ready(function() {
}
};
// Функция для сохранения ответа на текущий вопрос
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) {
@ -605,7 +739,7 @@ $(document).ready(function() {
const $tagInput = $('<input>', {
type: 'text',
placeholder: 'Введите теги, разделенные пробелом, и нажмите Enter',
placeholder: 'Введите тег и нажмите Enter',
class: 'tag-input'
});
@ -620,15 +754,10 @@ $(document).ready(function() {
const inputText = $(this).val().trim();
if (inputText) {
// Разбиваем ввод на отдельные теги
const tags = inputText.split(/\s+/);
// Добавляем каждый тег как отдельный элемент
tags.forEach(tagText => {
if (tagText) {
// Добавляем тег как отдельный элемент
const $tagItem = $('<div>', {
class: 'tag-item selected',
text: tagText
text: inputText
});
const $tagRemove = $('<span>', {
@ -643,8 +772,6 @@ $(document).ready(function() {
$tagItem.append($tagRemove);
$tagItems.append($tagItem);
}
});
$(this).val('');
updateTagValidation();
@ -679,11 +806,74 @@ $(document).ready(function() {
}
};
// Создание анимации конфети
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',
@ -730,109 +920,6 @@ $(document).ready(function() {
}, 100);
};
// Отправка формы
const submitForm = function() {
// Отключаем атрибут required у невидимых полей перед отправкой
$('input[required]:not(:visible), textarea[required]:not(:visible)').prop('required', false);
// Собираем ответы
const answers = [];
$.each(questionnaireData.questions, function(questionIndex, question) {
const $questionEl = $(`.question-item[data-index="${questionIndex}"]`);
let optionIndices;
let selectedOption;
let selectedOptions;
let selectedTags;
let textAnswer;
let scaleValue;
switch (question.type) {
case 'single_choice':
selectedOption = $questionEl.find(`input[name="question_${questionIndex}"]:checked`);
if (selectedOption.length) {
answers.push({
questionIndex,
optionIndices: [parseInt(selectedOption.val())]
});
}
break;
case 'multiple_choice':
selectedOptions = $questionEl.find('input[type="checkbox"]:checked:not(.multiple-choice-validation)');
if (selectedOptions.length) {
optionIndices = $.map(selectedOptions, function(option) {
const nameParts = $(option).attr('name').split('_');
return parseInt(nameParts[nameParts.length - 1]);
});
answers.push({
questionIndex,
optionIndices
});
}
break;
case 'text':
textAnswer = $questionEl.find('textarea').val().trim();
if (textAnswer) {
answers.push({
questionIndex,
textAnswer
});
}
break;
case 'scale':
case 'rating': // Обратная совместимость
selectedOption = $questionEl.find(`input[name="question_${questionIndex}"]:checked`);
if (selectedOption.length) {
answers.push({
questionIndex,
scaleValue: parseInt(selectedOption.val())
});
}
break;
case 'tag_cloud':
selectedTags = $questionEl.find('.tag-item.selected');
if (selectedTags.length) {
const tagTexts = $.map(selectedTags, function(tag) {
return $(tag).text().replace('×', '').trim();
});
answers.push({
questionIndex,
tagTexts
});
}
break;
}
});
// Отправляем ответы на сервер
$.ajax({
url: `${getApiPath()}/vote/${publicLink}`,
method: 'POST',
contentType: 'application/json',
data: JSON.stringify({ answers }),
success: function(result) {
if (result.success) {
// Сохраняем информацию о прохождении опроса
markAsCompleted();
// Показываем анимацию благодарности вместо сразу скрытия формы
showThankYouAnimation();
} else {
showAlert(`Ошибка: ${result.error}`, 'Ошибка');
}
},
error: function(error) {
console.error('Error submitting poll:', error);
showAlert('Не удалось отправить ответы. Пожалуйста, попробуйте позже.', 'Ошибка');
}
});
};
// Загрузка результатов опроса
const loadPollResults = () => {
$.ajax({
@ -864,6 +951,15 @@ $(document).ready(function() {
pollResultsContainerEl.append($resultsTitle);
// Проверяем наличие вопросов в данных
if (!data.questions || data.questions.length === 0) {
pollResultsContainerEl.append($('<p>', {
text: 'Нет данных для отображения',
class: 'no-results'
}));
return;
}
// Отображаем результаты с отложенной анимацией
data.questions.forEach((question, index) => {
setTimeout(() => {
@ -879,17 +975,68 @@ $(document).ready(function() {
$questionResult.append($questionTitle);
// Нормализация типа вопроса
const questionType = normalizeQuestionType(question.type);
// Отображаем результаты в зависимости от типа вопроса
switch (question.type) {
case 'single_choice':
case 'multiple_choice':
if (question.options && question.options.length > 0) {
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 = question.options.reduce((sum, option) => sum + (option.count || 0), 0);
const totalVotes = options.reduce((sum, option) => sum + (option.count || option.votes || 0), 0);
if (totalVotes > 0) {
question.options.forEach((option) => {
const percent = totalVotes > 0 ? Math.round((option.count || 0) * 100 / 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'
@ -911,12 +1058,12 @@ $(document).ready(function() {
const $resultPercent = $('<div>', {
class: 'result-percent',
text: `${percent}% (${option.count || 0} голосов)`
text: `${percent}% (${votes} голосов)`
});
$resultBar.append($resultBarFill);
$optionResult.append($optionLabel, $resultBar, $resultPercent);
$questionResult.append($optionResult);
$container.append($optionResult);
// Анимируем заполнение полосы после добавления в DOM
setTimeout(() => {
@ -924,26 +1071,31 @@ $(document).ready(function() {
}, 100);
});
} else {
$questionResult.append($('<p>', {
$container.append($('<p>', {
text: 'Пока нет голосов для этого вопроса',
class: 'no-results'
}));
}
}
break;
};
case 'tag_cloud':
if (question.tags && question.tags.length > 0) {
// Отображение результатов для облака тегов
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(...question.tags.map(tag => tag.count || 1));
const maxCount = Math.max(...tags.map(tag => tag.count || 1));
tags.forEach((tag, tagIndex) => {
if (!tag.text) return; // Пропускаем некорректные теги
question.tags.forEach((tag, tagIndex) => {
// Масштабируем размер тега от 1 до 3 в зависимости от частоты
const scale = maxCount > 1 ? 1 + (tag.count / maxCount) * 2 : 1;
const scale = maxCount > 1 ? 1 + ((tag.count || 1) / maxCount) * 2 : 1;
const $tag = $('<div>', {
class: 'result-tag',
@ -966,45 +1118,58 @@ $(document).ready(function() {
}, tagIndex * 100);
});
$questionResult.append($tagCloud);
$container.append($tagCloud);
} else {
$questionResult.append($('<p>', {
$container.append($('<p>', {
text: 'Пока нет тегов для этого вопроса',
class: 'no-results'
}));
}
break;
};
case 'scale':
case 'rating':
if (question.scaleValues && question.scaleValues.length > 0) {
const average = question.scaleAverage ||
(question.scaleValues.reduce((sum, val) => sum + val, 0) / question.scaleValues.length);
// Отображение результатов для шкалы и рейтинга
const renderScaleResults = (question, $container) => {
// Используем доступное поле для значений шкалы
const values = question.responses && question.responses.length > 0
? question.responses
: (question.scaleValues || []);
const min = Math.min(...question.scaleValues);
const max = Math.max(...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})`
});
$questionResult.append($scaleAverage);
$container.append($scaleAverage);
} else {
$questionResult.append($('<p>', {
$container.append($('<p>', {
text: 'Пока нет оценок для этого вопроса',
class: 'no-results'
}));
}
break;
};
case 'text':
if (question.answers && question.answers.length > 0) {
// Отображение результатов для текстовых вопросов
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'
});
question.answers.forEach((answer, answerIndex) => {
answers.forEach((answer, answerIndex) => {
const $textAnswer = $('<div>', {
class: 'text-answer',
text: answer,
@ -1025,35 +1190,13 @@ $(document).ready(function() {
}, answerIndex * 200);
});
$questionResult.append($answersContainer);
$container.append($answersContainer);
} else {
$questionResult.append($('<p>', {
$container.append($('<p>', {
text: 'Пока нет текстовых ответов для этого вопроса',
class: 'no-results'
}));
}
break;
}
pollResultsContainerEl.append($questionResult);
// Анимируем появление блока результатов
setTimeout(() => {
$questionResult.css({
opacity: 1,
transform: 'translateY(0)'
});
}, 100);
}, index * 300); // Задержка между вопросами
});
// Если нет данных для отображения
if (!data.questions || data.questions.length === 0) {
pollResultsContainerEl.append($('<p>', {
text: 'Нет данных для отображения',
class: 'no-results'
}));
}
};
// Обработка отправки формы