Merge branch 'staging'
This commit is contained in:
commit
44a1b5a4bb
@ -2,7 +2,7 @@ import { defineConfig } from "cypress";
|
|||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
e2e: {
|
e2e: {
|
||||||
baseUrl: "http://localhost:3000",
|
baseUrl: "http://localhost:5173",
|
||||||
viewportWidth: 1440,
|
viewportWidth: 1440,
|
||||||
viewportHeight: 900,
|
viewportHeight: 900,
|
||||||
supportFile: false,
|
supportFile: false,
|
||||||
|
|||||||
6
cypress/README
Normal file
6
cypress/README
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
Аккаунт с квизами для тестирования
|
||||||
|
testakk@mail.ru
|
||||||
|
testakktestakk
|
||||||
|
|
||||||
|
3ba7deeb-9bb6-4057-a5a3-798e935958a0 - БС, 3 варианты ответов, таймер 3 сек, quiz линейный BC3VT3SQL
|
||||||
|
e69d4d52-50f6-4c2a-8fb6-cf92adaf6ca0 - БС, 3 варианты ответов, таймер 3 сек, quiz ветвящийся BC3VT3SQВ
|
||||||
37
cypress/e2e/test-BC3VT3SQB.cy.ts
Normal file
37
cypress/e2e/test-BC3VT3SQB.cy.ts
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
describe("БС, 3 варианты ответов, таймер 3 сек, quiz ветвящийся BC3VT3SQB", () => {
|
||||||
|
it("необходимо завершить тест тремя вопросами, проверить отсутствие заголовка 'не ветвящийся' и проверить, отключена ли кнопка назад", () => {
|
||||||
|
cy.visit("/e69d4d52-50f6-4c2a-8fb6-cf92adaf6ca0");
|
||||||
|
cy.get("body").should("be.visible");
|
||||||
|
|
||||||
|
// Проверяем первый вопрос
|
||||||
|
cy.get("#test-question-title", { timeout: 10000 }).should("be.visible");
|
||||||
|
cy.get("#test-question-title").should("contain", "первый вопрос ветвления");
|
||||||
|
cy.get("#test-prev-button").should("be.disabled");
|
||||||
|
|
||||||
|
// Проверяем второй вопрос
|
||||||
|
cy.get("#test-question-title").should("be.visible");
|
||||||
|
cy.get("#test-question-title").should("contain", "второй вопрос ветвления");
|
||||||
|
cy.get("#test-prev-button").should("be.disabled");
|
||||||
|
|
||||||
|
// Проверяем третий вопрос
|
||||||
|
cy.get("#test-question-title").should("be.visible");
|
||||||
|
cy.get("#test-question-title").should("contain", "третий вопрос ветвления");
|
||||||
|
cy.get("#test-prev-button").should("be.disabled");
|
||||||
|
|
||||||
|
// Проверяем открытие страницы с контактной формой
|
||||||
|
cy.get("#test-contact-form").should("be.visible");
|
||||||
|
|
||||||
|
// Заполняем контактную форму
|
||||||
|
cy.get('input[placeholder*="Имя"], input[placeholder*="Name"]').type("Тестовое имя");
|
||||||
|
cy.get('input[placeholder*="Email"], input[placeholder*="Почта"]').type("test@example.com");
|
||||||
|
|
||||||
|
// Соглашаемся с условиями
|
||||||
|
cy.get('input[type="checkbox"]').check();
|
||||||
|
|
||||||
|
// Нажимаем кнопку получения результатов
|
||||||
|
cy.get("button").contains("Получить результаты").click();
|
||||||
|
|
||||||
|
// Проверяем открытие страницы с результатами
|
||||||
|
cy.get("#test-result-form").should("be.visible");
|
||||||
|
});
|
||||||
|
});
|
||||||
36
cypress/e2e/test-BC3VT3SQL.cy.ts
Normal file
36
cypress/e2e/test-BC3VT3SQL.cy.ts
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
describe("БС, 3 варианты ответов, таймер 3 сек, quiz ветвящийся BC3VT3SQL", () => {
|
||||||
|
it("необходимо завершить тест тремя вопросами и проверить, отключена ли кнопка назад", () => {
|
||||||
|
cy.visit("/3ba7deeb-9bb6-4057-a5a3-798e935958a0");
|
||||||
|
cy.get("body").should("be.visible");
|
||||||
|
|
||||||
|
// Проверяем первый вопрос
|
||||||
|
cy.get("#test-question-title", { timeout: 10000 }).should("be.visible");
|
||||||
|
cy.get("#test-question-title").should("contain", "первый вопрос");
|
||||||
|
cy.get("#test-prev-button").should("be.disabled");
|
||||||
|
|
||||||
|
// Проверяем второй вопрос
|
||||||
|
cy.get("#test-question-title").should("be.visible");
|
||||||
|
cy.get("#test-question-title").should("contain", "второй вопрос");
|
||||||
|
cy.get("#test-prev-button").should("be.disabled");
|
||||||
|
|
||||||
|
// Проверяем третий вопрос
|
||||||
|
cy.get("#test-question-title").should("be.visible");
|
||||||
|
cy.get("#test-question-title").should("contain", "третий вопрос");
|
||||||
|
cy.get("#test-prev-button").should("be.disabled");
|
||||||
|
|
||||||
|
// Проверяем открытие страницы с контактной формой
|
||||||
|
cy.get("#test-contact-form").should("be.visible");
|
||||||
|
|
||||||
|
// Заполняем контактную форму
|
||||||
|
cy.get('input[placeholder*="Email"], input[placeholder*="Почта"]').type("test@example.com");
|
||||||
|
|
||||||
|
// Соглашаемся с условиями
|
||||||
|
cy.get('input[type="checkbox"]').check();
|
||||||
|
|
||||||
|
// Нажимаем кнопку получения результатов
|
||||||
|
cy.get("button").contains("Получить результаты").click();
|
||||||
|
|
||||||
|
// Проверяем открытие страницы с результатами
|
||||||
|
cy.get("#test-result-form").should("be.visible");
|
||||||
|
});
|
||||||
|
});
|
||||||
248
cypress/e2e/test-apology-page.cy.ts
Normal file
248
cypress/e2e/test-apology-page.cy.ts
Normal file
@ -0,0 +1,248 @@
|
|||||||
|
/// <reference types="cypress" />
|
||||||
|
import "../support/commands";
|
||||||
|
|
||||||
|
describe("Проверка apology page", () => {
|
||||||
|
// Конфигурация тестов для разных языков
|
||||||
|
const localeConfigs = [
|
||||||
|
{
|
||||||
|
locale: "ru",
|
||||||
|
urlPrefix: "", // русский по умолчанию
|
||||||
|
description: "русский (по умолчанию)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
locale: "ru",
|
||||||
|
urlPrefix: "/ru",
|
||||||
|
description: "русский (явно указан)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
locale: "en",
|
||||||
|
urlPrefix: "/en",
|
||||||
|
description: "английский",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
locale: "uz",
|
||||||
|
urlPrefix: "/uz",
|
||||||
|
description: "узбекский",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// Тестовые случаи с ожидаемыми ошибками
|
||||||
|
const testCases = [
|
||||||
|
{
|
||||||
|
name: "неопубликованный квиз",
|
||||||
|
path: "/b0d3c6d1-49b8-4cf8-bb4c-bde05eebb659",
|
||||||
|
expectedError: "quiz is inactive",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "опубликованный квиз без вопросов",
|
||||||
|
path: "/228df092-1f9b-4c04-b08d-08726f0ef223",
|
||||||
|
expectedError: "no questions found",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "пустой qid",
|
||||||
|
path: "/",
|
||||||
|
expectedError: "no quiz id",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "некорректный qid",
|
||||||
|
path: "/invalid-quiz-id-12345",
|
||||||
|
expectedError: "invalid request data",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "несуществующий qid",
|
||||||
|
path: "/00000000-0000-0000-0000-000000000000",
|
||||||
|
expectedError: "invalid request data",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "qid с неправильным форматом",
|
||||||
|
path: "/not-a-valid-uuid",
|
||||||
|
expectedError: "invalid request data",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// Базовые тесты без проверки текста (для всех случаев)
|
||||||
|
describe("Базовые проверки отображения apology page", () => {
|
||||||
|
it("необходимо проверить, что apology page отображает свои ошибки", () => {
|
||||||
|
cy.visit("/apology-page");
|
||||||
|
cy.get("#test-apology-page").should("be.visible");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("должен показать apology page для неопубликованного квиза", () => {
|
||||||
|
cy.visit("/b0d3c6d1-49b8-4cf8-bb4c-bde05eebb659");
|
||||||
|
cy.get("#test-apology-page").should("be.visible");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("должен показать apology page для опубликованного квиза без вопросов", () => {
|
||||||
|
cy.visit("/228df092-1f9b-4c04-b08d-08726f0ef223");
|
||||||
|
cy.get("#test-apology-page").should("be.visible");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("должен показать apology page для пустого qid", () => {
|
||||||
|
cy.visit("/");
|
||||||
|
cy.get("#test-apology-page").should("be.visible");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("должен показать apology page для некорректного qid", () => {
|
||||||
|
cy.visit("/invalid-quiz-id-12345");
|
||||||
|
cy.get("#test-apology-page").should("be.visible");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("должен показать apology page для несуществующего qid", () => {
|
||||||
|
cy.visit("/00000000-0000-0000-0000-000000000000");
|
||||||
|
cy.get("#test-apology-page").should("be.visible");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("должен показать apology page для qid с неправильным форматом", () => {
|
||||||
|
cy.visit("/not-a-valid-uuid");
|
||||||
|
cy.get("#test-apology-page").should("be.visible");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("должен показать apology page для qid с пустой строкой", () => {
|
||||||
|
cy.visit("/ ");
|
||||||
|
cy.get("#test-apology-page").should("be.visible");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("должен показать apology page для qid с null значением", () => {
|
||||||
|
cy.visit("/null");
|
||||||
|
cy.get("#test-apology-page").should("be.visible");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("должен показать apology page для qid с undefined значением", () => {
|
||||||
|
cy.visit("/undefined");
|
||||||
|
cy.get("#test-apology-page").should("be.visible");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("должен показать apology page для qid с пустой строкой в URL", () => {
|
||||||
|
cy.visit("/%20");
|
||||||
|
cy.get("#test-apology-page").should("be.visible");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("должен показать apology page для qid с специальными символами", () => {
|
||||||
|
cy.visit("/!@#$%^&*()");
|
||||||
|
cy.get("#test-apology-page").should("be.visible");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("должен показать apology page для qid с очень длинной строкой", () => {
|
||||||
|
cy.visit("/" + "a".repeat(1000));
|
||||||
|
cy.get("#test-apology-page").should("be.visible");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("должен показать apology page для qid с SQL инъекцией", () => {
|
||||||
|
cy.visit("/'; DROP TABLE quizzes; --");
|
||||||
|
cy.get("#test-apology-page").should("be.visible");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("должен показать apology page для qid с XSS попыткой", () => {
|
||||||
|
cy.visit("/<script>alert('xss')</script>");
|
||||||
|
cy.get("#test-apology-page").should("be.visible");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Параметризованные тесты с проверкой локализации
|
||||||
|
localeConfigs.forEach(({ locale, urlPrefix, description }) => {
|
||||||
|
describe(`Локализация: ${description}`, () => {
|
||||||
|
testCases.forEach(({ name, path, expectedError }) => {
|
||||||
|
it(`показывает правильную ошибку для "${name}" на ${description}`, () => {
|
||||||
|
const fullUrl = `${urlPrefix}${path}`;
|
||||||
|
|
||||||
|
cy.visit(fullUrl);
|
||||||
|
cy.get("#test-apology-page").should("be.visible");
|
||||||
|
|
||||||
|
// Проверяем что отображается правильный текст ошибки с использованием кешированных переводов
|
||||||
|
cy.shouldHaveTranslation("#test-apology-page", expectedError, locale);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Специальные тесты для разных типов ошибок
|
||||||
|
it(`показывает ошибку неактивного квиза на ${description}`, () => {
|
||||||
|
cy.visit(`${urlPrefix}/b0d3c6d1-49b8-4cf8-bb4c-bde05eebb659`);
|
||||||
|
cy.get("#test-apology-page").should("be.visible");
|
||||||
|
cy.shouldHaveTranslation("#test-apology-page", "quiz is inactive", locale);
|
||||||
|
});
|
||||||
|
|
||||||
|
it(`показывает ошибку отсутствия вопросов на ${description}`, () => {
|
||||||
|
cy.visit(`${urlPrefix}/228df092-1f9b-4c04-b08d-08726f0ef223`);
|
||||||
|
cy.get("#test-apology-page").should("be.visible");
|
||||||
|
cy.shouldHaveTranslation("#test-apology-page", "no questions found", locale);
|
||||||
|
});
|
||||||
|
|
||||||
|
it(`показывает ошибку отсутствия quiz id на ${description}`, () => {
|
||||||
|
cy.visit(`${urlPrefix}/`);
|
||||||
|
cy.get("#test-apology-page").should("be.visible");
|
||||||
|
cy.shouldHaveTranslation("#test-apology-page", "no quiz id", locale);
|
||||||
|
});
|
||||||
|
|
||||||
|
it(`показывает ошибку невалидных данных на ${description}`, () => {
|
||||||
|
cy.visit(`${urlPrefix}/invalid-quiz-id-12345`);
|
||||||
|
cy.get("#test-apology-page").should("be.visible");
|
||||||
|
cy.shouldHaveTranslation("#test-apology-page", "invalid request data", locale);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Дополнительные тесты для проверки приоритета локалей
|
||||||
|
describe("Приоритет выбора локали", () => {
|
||||||
|
it("использует русскую локаль по умолчанию когда язык не указан", () => {
|
||||||
|
cy.visit("/b0d3c6d1-49b8-4cf8-bb4c-bde05eebb659");
|
||||||
|
cy.get("#test-apology-page").should("be.visible");
|
||||||
|
cy.shouldHaveTranslation("#test-apology-page", "quiz is inactive", "ru");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("использует явно указанную русскую локаль", () => {
|
||||||
|
cy.visit("/ru/b0d3c6d1-49b8-4cf8-bb4c-bde05eebb659");
|
||||||
|
cy.get("#test-apology-page").should("be.visible");
|
||||||
|
cy.shouldHaveTranslation("#test-apology-page", "quiz is inactive", "ru");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("использует английскую локаль когда указан /en", () => {
|
||||||
|
cy.visit("/en/b0d3c6d1-49b8-4cf8-bb4c-bde05eebb659");
|
||||||
|
cy.get("#test-apology-page").should("be.visible");
|
||||||
|
cy.shouldHaveTranslation("#test-apology-page", "quiz is inactive", "en");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("использует узбекскую локаль когда указан /uz", () => {
|
||||||
|
cy.visit("/uz/b0d3c6d1-49b8-4cf8-bb4c-bde05eebb659");
|
||||||
|
cy.get("#test-apology-page").should("be.visible");
|
||||||
|
cy.shouldHaveTranslation("#test-apology-page", "quiz is inactive", "uz");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Тесты для проверки доступности файлов переводов
|
||||||
|
describe("Доступность файлов переводов", () => {
|
||||||
|
it("файл перевода ru.json доступен и содержит все ключи", () => {
|
||||||
|
cy.loadTranslations("ru").then((translations) => {
|
||||||
|
expect(translations).to.have.property("quiz is inactive");
|
||||||
|
expect(translations).to.have.property("no_questions_found");
|
||||||
|
expect(translations).to.have.property("quiz is empty");
|
||||||
|
expect(translations).to.have.property("quiz already completed");
|
||||||
|
expect(translations).to.have.property("no quiz id");
|
||||||
|
expect(translations).to.have.property("quiz data is null");
|
||||||
|
expect(translations).to.have.property("invalid request data");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("файл перевода en.json доступен и содержит все ключи", () => {
|
||||||
|
cy.loadTranslations("en").then((translations) => {
|
||||||
|
expect(translations).to.have.property("quiz_is_inactive");
|
||||||
|
expect(translations).to.have.property("no_questions_found");
|
||||||
|
expect(translations).to.have.property("quiz_is_empty");
|
||||||
|
expect(translations).to.have.property("quiz_already_completed");
|
||||||
|
expect(translations).to.have.property("no_quiz_id");
|
||||||
|
expect(translations).to.have.property("quiz_data_is_null");
|
||||||
|
expect(translations).to.have.property("invalid_request_data");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("файл перевода uz.json доступен и содержит все ключи", () => {
|
||||||
|
cy.loadTranslations("uz").then((translations) => {
|
||||||
|
expect(translations).to.have.property("quiz_is_inactive");
|
||||||
|
expect(translations).to.have.property("no_questions_found");
|
||||||
|
expect(translations).to.have.property("quiz_is_empty");
|
||||||
|
expect(translations).to.have.property("quiz_already_completed");
|
||||||
|
expect(translations).to.have.property("no_quiz_id");
|
||||||
|
expect(translations).to.have.property("quiz_data_is_null");
|
||||||
|
expect(translations).to.have.property("invalid_request_data");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
72
cypress/support/commands.ts
Normal file
72
cypress/support/commands.ts
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
// Глобальная Map для кеширования переводов
|
||||||
|
const translationCache = new Map();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Загружает переводы для указанной локали с кешированием
|
||||||
|
* Если переводы уже загружены - возвращает из кеша
|
||||||
|
*/
|
||||||
|
Cypress.Commands.add("loadTranslations", (locale: string = "ru") => {
|
||||||
|
// Проверяем есть ли уже переводы в кеше для этой локали
|
||||||
|
if (translationCache.has(locale)) {
|
||||||
|
cy.log(`Переводы для локали "${locale}" уже загружены, используем кеш`);
|
||||||
|
return cy.wrap(translationCache.get(locale));
|
||||||
|
}
|
||||||
|
|
||||||
|
cy.log(`Загружаем переводы для локали: ${locale}`);
|
||||||
|
|
||||||
|
// Делаем HTTP запрос к файлу перевода
|
||||||
|
return cy
|
||||||
|
.request({
|
||||||
|
url: `/locales/${locale}.json`,
|
||||||
|
failOnStatusCode: true,
|
||||||
|
})
|
||||||
|
.then((response) => {
|
||||||
|
// Проверяем что запрос успешен
|
||||||
|
if (response.status !== 200) {
|
||||||
|
throw new Error(`Не удалось загрузить переводы для локали ${locale}. Status: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем что ответ содержит данные
|
||||||
|
if (!response.body || typeof response.body !== "object") {
|
||||||
|
throw new Error(`Получен некорректный ответ для локали ${locale}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сохраняем переводы в кеш
|
||||||
|
translationCache.set(locale, response.body);
|
||||||
|
cy.log(
|
||||||
|
`Переводы для локали "${locale}" успешно загружены и закешированы. Ключей: ${Object.keys(response.body).length}`
|
||||||
|
);
|
||||||
|
|
||||||
|
return cy.wrap(response.body);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Получает конкретный перевод по ключу с использованием кеша
|
||||||
|
*/
|
||||||
|
Cypress.Commands.add("getTranslation", (key: string, locale: string = "ru") => {
|
||||||
|
// Сначала загружаем переводы (использует кеш если уже загружены)
|
||||||
|
return cy.loadTranslations(locale).then((translations) => {
|
||||||
|
// Проверяем что ключ существует в переводах
|
||||||
|
if (!translations.hasOwnProperty(key)) {
|
||||||
|
const availableKeys = Object.keys(translations).join(", ");
|
||||||
|
throw new Error(`Ключ перевода "${key}" не найден в локали "${locale}". Доступные ключи: ${availableKeys}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const translatedText = translations[key];
|
||||||
|
cy.log(`Перевод для ключа "${key}" в локали "${locale}": "${translatedText}"`);
|
||||||
|
|
||||||
|
return cy.wrap(translatedText as unknown as string);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Проверяет что элемент содержит правильный перевод
|
||||||
|
*/
|
||||||
|
Cypress.Commands.add("shouldHaveTranslation", (selector: string, key: string, locale: string = "ru") => {
|
||||||
|
// Получаем ожидаемый текст через кешированные переводы
|
||||||
|
cy.getTranslation(key, locale).then((expectedText) => {
|
||||||
|
// Проверяем что элемент содержит ожидаемый текст
|
||||||
|
cy.get(selector).should("contain", expectedText);
|
||||||
|
});
|
||||||
|
});
|
||||||
7
cypress/support/index.d.ts
vendored
Normal file
7
cypress/support/index.d.ts
vendored
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
declare namespace Cypress {
|
||||||
|
interface Chainable {
|
||||||
|
loadTranslations(locale?: string): Chainable<any>;
|
||||||
|
getTranslation(key: string, locale?: string): Chainable<string>;
|
||||||
|
shouldHaveTranslation(selector: string, key: string, locale?: string): Chainable<void>;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -10,6 +10,7 @@ export const ApologyPage = ({ error }: Props) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
|
id="test-apology-page"
|
||||||
sx={{
|
sx={{
|
||||||
display: "flex",
|
display: "flex",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
@ -24,7 +25,7 @@ export const ApologyPage = ({ error }: Props) => {
|
|||||||
color: "text.primary",
|
color: "text.primary",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{t(message.toLowerCase())}
|
{/* {t(message.toLowerCase())} */}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -301,6 +301,7 @@ export const ContactForm = ({ currentQuestion, onShowResult }: Props) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
|
id="test-contact-form"
|
||||||
sx={{
|
sx={{
|
||||||
display: "flex",
|
display: "flex",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
|
|||||||
@ -24,13 +24,13 @@ type InputProps = {
|
|||||||
const TextField = MuiTextField as unknown as FC<TextFieldProps>;
|
const TextField = MuiTextField as unknown as FC<TextFieldProps>;
|
||||||
let first = true;
|
let first = true;
|
||||||
|
|
||||||
function phoneChange(e: ChangeEvent<HTMLInputElement>, mask: string) {
|
function phoneChange(newValue: string, mask: string) {
|
||||||
const masked = IMask.createMask({
|
const masked = IMask.createMask({
|
||||||
mask: "+7 (000) 000-00-00",
|
mask: "+7 (000) 000-00-00",
|
||||||
// ...and other options
|
// ...and other options
|
||||||
});
|
});
|
||||||
masked.value = e.target.value;
|
masked.value = newValue;
|
||||||
const a = IMask.pipe(e.target.value, {
|
const a = IMask.pipe(newValue, {
|
||||||
mask,
|
mask,
|
||||||
});
|
});
|
||||||
return a || "";
|
return a || "";
|
||||||
@ -68,7 +68,7 @@ export const CustomInput = ({
|
|||||||
// inputRef={isPhone ? ref : null}
|
// inputRef={isPhone ? ref : null}
|
||||||
//@ts-ignore
|
//@ts-ignore
|
||||||
onChange={(e: ChangeEvent<HTMLInputElement>) =>
|
onChange={(e: ChangeEvent<HTMLInputElement>) =>
|
||||||
isPhone ? onChangePhone?.(phoneChange(e, mask)) : onChange?.(e)
|
isPhone ? onChangePhone?.(phoneChange(e.target.value, mask)) : onChange?.(e)
|
||||||
}
|
}
|
||||||
onBlur={onBlur}
|
onBlur={onBlur}
|
||||||
type={isPhone ? "tel" : type}
|
type={isPhone ? "tel" : type}
|
||||||
@ -106,7 +106,16 @@ export const CustomInput = ({
|
|||||||
</InputAdornment>
|
</InputAdornment>
|
||||||
),
|
),
|
||||||
endAdornment: (
|
endAdornment: (
|
||||||
<InputAdornment position="end">{isPhone && <CountrySelector setMask={setMask} />}</InputAdornment>
|
<InputAdornment position="end">
|
||||||
|
{isPhone && (
|
||||||
|
<CountrySelector
|
||||||
|
setMask={(e) => {
|
||||||
|
onChangePhone?.(phoneChange("", mask));
|
||||||
|
setMask(e);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</InputAdornment>
|
||||||
),
|
),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -24,6 +24,9 @@ import { DESIGN_LIST } from "@/utils/designList";
|
|||||||
import { type ReactNode } from "react";
|
import { type ReactNode } from "react";
|
||||||
import { isProduction } from "@/utils/defineDomain";
|
import { isProduction } from "@/utils/defineDomain";
|
||||||
import { useQuizStore } from "@/stores/useQuizStore";
|
import { useQuizStore } from "@/stores/useQuizStore";
|
||||||
|
import { CustomCircularTimer } from "@/ui_kit/timer/CircularTimer";
|
||||||
|
import { useQuestionTimer } from "@/utils/hooks/FlowControlLogic/useQuestionTimer";
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
currentQuestion: RealTypedQuizQuestion;
|
currentQuestion: RealTypedQuizQuestion;
|
||||||
@ -41,7 +44,38 @@ export const Question = ({
|
|||||||
questionSelect,
|
questionSelect,
|
||||||
}: Props) => {
|
}: Props) => {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const { settings, show_badge, quizId } = useQuizStore();
|
const { settings, show_badge, quizId, preview } = useQuizStore();
|
||||||
|
|
||||||
|
// Состояние для отслеживания оставшегося времени
|
||||||
|
const [remainingTime, setRemainingTime] = useState<number>(0);
|
||||||
|
const [isTimerActive, setIsTimerActive] = useState<boolean>(false);
|
||||||
|
|
||||||
|
// Получаем настройки таймера
|
||||||
|
const timerEnabled = Boolean(settings.questionTimerEnabled);
|
||||||
|
const timerDuration = settings.cfg.time_of_passing ?? 0;
|
||||||
|
|
||||||
|
// Эффект для обновления таймера
|
||||||
|
useEffect(() => {
|
||||||
|
if (timerEnabled && timerDuration > 0) {
|
||||||
|
setRemainingTime(timerDuration);
|
||||||
|
setIsTimerActive(true);
|
||||||
|
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
setRemainingTime((prev) => {
|
||||||
|
if (prev <= 1) {
|
||||||
|
setIsTimerActive(false);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return prev - 1;
|
||||||
|
});
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
} else {
|
||||||
|
setIsTimerActive(false);
|
||||||
|
setRemainingTime(0);
|
||||||
|
}
|
||||||
|
}, [timerEnabled, timerDuration, currentQuestion.id]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
@ -93,14 +127,29 @@ export const Question = ({
|
|||||||
question={currentQuestion}
|
question={currentQuestion}
|
||||||
stepNumber={currentQuestionStepNumber}
|
stepNumber={currentQuestionStepNumber}
|
||||||
/>
|
/>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
mt: "20px",
|
||||||
|
alignSelf: "end",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: "end",
|
||||||
|
gap: "13px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{timerEnabled && isTimerActive && (
|
||||||
|
<CustomCircularTimer
|
||||||
|
duration={timerDuration}
|
||||||
|
remaining={remainingTime}
|
||||||
|
showTime={true}
|
||||||
|
size={76}
|
||||||
|
thickness={4}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{show_badge && (
|
{show_badge && (
|
||||||
<Link
|
<Link
|
||||||
target="_blank"
|
target="_blank"
|
||||||
href={`https://${isProduction ? "" : "s"}quiz.pena.digital/answer/v1.0.0/logo?q=${quizId}`}
|
href={`https://${isProduction ? "" : "s"}quiz.pena.digital/answer/v1.0.0/logo?q=${quizId}`}
|
||||||
sx={{
|
|
||||||
mt: "20px",
|
|
||||||
alignSelf: "end",
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{quizThemes[settings.cfg.theme].isLight ? (
|
{quizThemes[settings.cfg.theme].isLight ? (
|
||||||
<NameplateLogoFQ
|
<NameplateLogoFQ
|
||||||
@ -123,6 +172,7 @@ export const Question = ({
|
|||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
</Box>
|
||||||
{questionSelect}
|
{questionSelect}
|
||||||
<Footer
|
<Footer
|
||||||
stepNumber={currentQuestionStepNumber}
|
stepNumber={currentQuestionStepNumber}
|
||||||
|
|||||||
@ -123,6 +123,7 @@ export const ResultForm = ({ resultQuestion }: ResultFormProps) => {
|
|||||||
}, [resultQuestion]);
|
}, [resultQuestion]);
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
|
id="test-result-form"
|
||||||
sx={{
|
sx={{
|
||||||
display: "flex",
|
display: "flex",
|
||||||
flexDirection: "column",
|
flexDirection: "column",
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import { sendQuestionAnswer } from "@/utils/sendQuestionAnswer";
|
|||||||
import { ThemeProvider, Typography } from "@mui/material";
|
import { ThemeProvider, Typography } from "@mui/material";
|
||||||
import { useQuizViewStore } from "@stores/quizView";
|
import { useQuizViewStore } from "@stores/quizView";
|
||||||
import { statusOfQuiz, useQuestionFlowControl } from "@utils/hooks/useQuestionFlowControl";
|
import { statusOfQuiz, useQuestionFlowControl } from "@utils/hooks/useQuestionFlowControl";
|
||||||
|
import { QuizStep } from "@/model/settingsData";
|
||||||
import { notReachable } from "@utils/notReachable";
|
import { notReachable } from "@utils/notReachable";
|
||||||
import { quizThemes } from "@utils/themes/Publication/themePublication";
|
import { quizThemes } from "@utils/themes/Publication/themePublication";
|
||||||
import { enqueueSnackbar } from "notistack";
|
import { enqueueSnackbar } from "notistack";
|
||||||
@ -27,7 +28,8 @@ export default function ViewPublicationPage() {
|
|||||||
const { settings, recentlyCompleted, quizId, preview, changeFaviconAndTitle } = useQuizStore();
|
const { settings, recentlyCompleted, quizId, preview, changeFaviconAndTitle } = useQuizStore();
|
||||||
const answers = useQuizViewStore((state) => state.answers);
|
const answers = useQuizViewStore((state) => state.answers);
|
||||||
const ownVariants = useQuizViewStore((state) => state.ownVariants);
|
const ownVariants = useQuizViewStore((state) => state.ownVariants);
|
||||||
let currentQuizStep = useQuizViewStore((state) => state.currentQuizStep);
|
const currentQuizStep = useQuizViewStore((state) => state.currentQuizStep);
|
||||||
|
const setCurrentQuizStep = useQuizViewStore((state) => state.setCurrentQuizStep);
|
||||||
const {
|
const {
|
||||||
currentQuestion,
|
currentQuestion,
|
||||||
currentQuestionStepNumber,
|
currentQuestionStepNumber,
|
||||||
@ -68,7 +70,18 @@ export default function ViewPublicationPage() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (settings.cfg.antifraud && recentlyCompleted) throw new Error("Quiz already completed");
|
if (settings.cfg.antifraud && recentlyCompleted) throw new Error("Quiz already completed");
|
||||||
if (currentQuizStep === "startpage" && settings.cfg.noStartPage) currentQuizStep = "question";
|
|
||||||
|
// Обработка noStartPage - обновляем состояние в store
|
||||||
|
useEffect(() => {
|
||||||
|
if (currentQuizStep === "startpage" && settings.cfg.noStartPage) {
|
||||||
|
console.log("🔄 noStartPage: Setting currentQuizStep to 'question'");
|
||||||
|
setCurrentQuizStep("question");
|
||||||
|
}
|
||||||
|
}, [currentQuizStep, settings.cfg.noStartPage, setCurrentQuizStep]);
|
||||||
|
|
||||||
|
// Определяем текущий шаг для рендеринга
|
||||||
|
const displayQuizStep: QuizStep =
|
||||||
|
currentQuizStep === "startpage" && settings.cfg.noStartPage ? "question" : currentQuizStep;
|
||||||
|
|
||||||
if (!currentQuestion) {
|
if (!currentQuestion) {
|
||||||
return (
|
return (
|
||||||
@ -86,7 +99,7 @@ export default function ViewPublicationPage() {
|
|||||||
const currentAnswer = answers.find(({ questionId }) => questionId === currentQuestion.id);
|
const currentAnswer = answers.find(({ questionId }) => questionId === currentQuestion.id);
|
||||||
|
|
||||||
let quizStepElement: ReactElement;
|
let quizStepElement: ReactElement;
|
||||||
switch (currentQuizStep) {
|
switch (displayQuizStep) {
|
||||||
case "startpage": {
|
case "startpage": {
|
||||||
quizStepElement = <StartPageViewPublication />;
|
quizStepElement = <StartPageViewPublication />;
|
||||||
break;
|
break;
|
||||||
@ -143,7 +156,7 @@ export default function ViewPublicationPage() {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
notReachable(currentQuizStep);
|
notReachable(displayQuizStep);
|
||||||
}
|
}
|
||||||
|
|
||||||
const preloadLinks = new Set([
|
const preloadLinks = new Set([
|
||||||
|
|||||||
@ -79,6 +79,7 @@ export const Variant = ({ currentQuestion }: VariantProps) => {
|
|||||||
return (
|
return (
|
||||||
<Box>
|
<Box>
|
||||||
<Typography
|
<Typography
|
||||||
|
id="test-question-title"
|
||||||
variant="h5"
|
variant="h5"
|
||||||
color={theme.palette.text.primary}
|
color={theme.palette.text.primary}
|
||||||
sx={{ wordBreak: "break-word" }}
|
sx={{ wordBreak: "break-word" }}
|
||||||
|
|||||||
@ -23,6 +23,7 @@ export default function NextButton({ isNextButtonEnabled, moveToNextQuestion }:
|
|||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Button
|
<Button
|
||||||
|
id="test-next-button"
|
||||||
disabled={!isNextButtonEnabled}
|
disabled={!isNextButtonEnabled}
|
||||||
variant="contained"
|
variant="contained"
|
||||||
sx={{
|
sx={{
|
||||||
|
|||||||
@ -17,6 +17,7 @@ export default function PrevButton({ isPreviousButtonEnabled, moveToPrevQuestion
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
|
id="test-prev-button"
|
||||||
disabled={!isPreviousButtonEnabled}
|
disabled={!isPreviousButtonEnabled}
|
||||||
variant="contained"
|
variant="contained"
|
||||||
sx={{
|
sx={{
|
||||||
|
|||||||
@ -66,16 +66,20 @@ export function parseQuizData(quizDataResponse: GetQuizDataResponse): Omit<QuizS
|
|||||||
readyData.questions = items;
|
readyData.questions = items;
|
||||||
|
|
||||||
if (quizDataResponse?.settings !== undefined) {
|
if (quizDataResponse?.settings !== undefined) {
|
||||||
|
const parsedCfg = JSON.parse(quizDataResponse?.settings.cfg);
|
||||||
|
|
||||||
readyData.settings = {
|
readyData.settings = {
|
||||||
fp: quizDataResponse.settings.fp,
|
fp: quizDataResponse.settings.fp,
|
||||||
rep: quizDataResponse.settings.rep,
|
rep: quizDataResponse.settings.rep,
|
||||||
name: quizDataResponse.settings.name,
|
name: quizDataResponse.settings.name,
|
||||||
cfg: JSON.parse(quizDataResponse?.settings.cfg),
|
cfg: parsedCfg,
|
||||||
lim: quizDataResponse.settings.lim,
|
lim: quizDataResponse.settings.lim,
|
||||||
due: quizDataResponse.settings.due,
|
due: quizDataResponse.settings.due,
|
||||||
delay: quizDataResponse.settings.delay,
|
delay: quizDataResponse.settings.delay,
|
||||||
pausable: quizDataResponse.settings.pausable,
|
pausable: quizDataResponse.settings.pausable,
|
||||||
status: quizDataResponse.settings.status,
|
status: quizDataResponse.settings.status,
|
||||||
|
// Автоматически включаем таймер, если time_of_passing > 0
|
||||||
|
questionTimerEnabled: parsedCfg.time_of_passing > 0,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -54,6 +54,8 @@ export type QuizSettingsConfig = {
|
|||||||
pausable: boolean;
|
pausable: boolean;
|
||||||
cfg: QuizConfig;
|
cfg: QuizConfig;
|
||||||
status: Status;
|
status: Status;
|
||||||
|
// Таймер вопросов
|
||||||
|
questionTimerEnabled?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type QuizSettings = {
|
export type QuizSettings = {
|
||||||
@ -65,6 +67,7 @@ export type QuizSettings = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export interface QuizConfig {
|
export interface QuizConfig {
|
||||||
|
time_of_passing?: number;
|
||||||
isUnSc?: boolean;
|
isUnSc?: boolean;
|
||||||
spec: undefined | true;
|
spec: undefined | true;
|
||||||
type: QuizType;
|
type: QuizType;
|
||||||
|
|||||||
128
lib/ui_kit/timer/CircularTimer.tsx
Normal file
128
lib/ui_kit/timer/CircularTimer.tsx
Normal file
@ -0,0 +1,128 @@
|
|||||||
|
import { CircularProgress, Box, Typography, useTheme, styled } from "@mui/material";
|
||||||
|
|
||||||
|
// Типизация для пропсов таймера
|
||||||
|
export interface CircularTimerProps {
|
||||||
|
duration: number; // Общая длительность в секундах
|
||||||
|
remaining: number; // Оставшееся время в секундах
|
||||||
|
showTime?: boolean; // Показывать ли время в формате mm:ss
|
||||||
|
size?: number; // Размер таймера
|
||||||
|
thickness?: number; // Толщина линии прогресса
|
||||||
|
color?: string; // Цвет прогресса
|
||||||
|
}
|
||||||
|
|
||||||
|
const StyledCircularProgress = styled(CircularProgress)(({ theme }) => ({
|
||||||
|
"& .MuiCircularProgress-circle": {
|
||||||
|
strokeLinecap: "round",
|
||||||
|
transition: "stroke-dashoffset 0.3s ease",
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Функция для форматирования времени в mm:ss
|
||||||
|
const formatTime = (seconds: number): string => {
|
||||||
|
const minutes = Math.floor(seconds / 60);
|
||||||
|
const remainingSeconds = Math.floor(seconds % 60);
|
||||||
|
return `${minutes.toString().padStart(2, "0")}:${remainingSeconds.toString().padStart(2, "0")}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CustomCircularTimer: React.FC<CircularTimerProps> = ({
|
||||||
|
duration,
|
||||||
|
remaining,
|
||||||
|
showTime = true,
|
||||||
|
size = 76,
|
||||||
|
thickness = 4,
|
||||||
|
color,
|
||||||
|
}) => {
|
||||||
|
const theme = useTheme();
|
||||||
|
const progress = (remaining / duration) * 100;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
id="test-timer"
|
||||||
|
sx={{ position: "relative", display: "inline-flex", width: size, height: size }}
|
||||||
|
>
|
||||||
|
{/* Серый фон */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
border: "#9A9AAF solid 1px",
|
||||||
|
position: "absolute",
|
||||||
|
height: `${size - 4}px`,
|
||||||
|
width: `${size - 4}px`,
|
||||||
|
borderRadius: "100%",
|
||||||
|
top: "2px",
|
||||||
|
left: "2px",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Основной прогресс */}
|
||||||
|
<StyledCircularProgress
|
||||||
|
variant="determinate"
|
||||||
|
value={progress}
|
||||||
|
size={size}
|
||||||
|
thickness={thickness}
|
||||||
|
sx={{
|
||||||
|
color: color || "linear-gradient(135deg, #FC712F 0%, #7E2AEA 100%)",
|
||||||
|
position: "absolute",
|
||||||
|
|
||||||
|
"& .MuiCircularProgress-circle": {
|
||||||
|
strokeLinecap: "round",
|
||||||
|
stroke: color ? undefined : "url(#timer-gradient)",
|
||||||
|
strokeDasharray: color ? undefined : undefined,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<svg
|
||||||
|
width={0}
|
||||||
|
height={0}
|
||||||
|
>
|
||||||
|
<defs>
|
||||||
|
<linearGradient
|
||||||
|
id="timer-gradient"
|
||||||
|
x1="0%"
|
||||||
|
y1="0%"
|
||||||
|
x2="100%"
|
||||||
|
y2="100%"
|
||||||
|
>
|
||||||
|
<stop
|
||||||
|
offset="9.9%"
|
||||||
|
stopColor="#FC712F"
|
||||||
|
/>
|
||||||
|
<stop
|
||||||
|
offset="73.88%"
|
||||||
|
stopColor="#7E2AEA"
|
||||||
|
/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
{/* Центральный контент */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
position: "absolute",
|
||||||
|
top: "50%",
|
||||||
|
left: "50%",
|
||||||
|
transform: "translate(-50%, -50%)",
|
||||||
|
width: `${size - 20}px`,
|
||||||
|
height: `${size - 20}px`,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography
|
||||||
|
variant="body1"
|
||||||
|
fontWeight="bold"
|
||||||
|
sx={{
|
||||||
|
fontSize: size > 60 ? "16px" : "12px",
|
||||||
|
fontWeight: 600,
|
||||||
|
color: theme.palette.text.primary,
|
||||||
|
textAlign: "center",
|
||||||
|
lineHeight: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{showTime ? formatTime(remaining) : remaining}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -9,10 +9,19 @@ import { useQuizViewStore } from "@stores/quizView";
|
|||||||
|
|
||||||
import { useVkMetricsGoals } from "@/utils/hooks/metrics/useVkMetricsGoals";
|
import { useVkMetricsGoals } from "@/utils/hooks/metrics/useVkMetricsGoals";
|
||||||
import { useYandexMetricsGoals } from "@/utils/hooks/metrics/useYandexMetricsGoals";
|
import { useYandexMetricsGoals } from "@/utils/hooks/metrics/useYandexMetricsGoals";
|
||||||
|
import { useQuestionTimer } from "./useQuestionTimer";
|
||||||
|
|
||||||
export function useAIQuiz() {
|
export function useAIQuiz() {
|
||||||
//Получаем инфо о квизе и список вопросов.
|
//Получаем инфо о квизе и список вопросов.
|
||||||
const { settings, questions, quizId, cnt, quizStep } = useQuizStore();
|
const { settings, questions, quizId, cnt, quizStep, preview } = useQuizStore();
|
||||||
|
|
||||||
|
// Отладочная информация о настройках таймера
|
||||||
|
console.log("🤖 AI Quiz settings:", {
|
||||||
|
questionTimerEnabled: settings.questionTimerEnabled,
|
||||||
|
time_of_passing: settings.cfg.time_of_passing,
|
||||||
|
timerEnabled: Boolean(settings.questionTimerEnabled),
|
||||||
|
timerSeconds: settings.cfg.time_of_passing ?? 0,
|
||||||
|
});
|
||||||
|
|
||||||
//Список ответов на вопрос. Мы записываем ответы локально, параллельно отправляя на бек информацию о ответах
|
//Список ответов на вопрос. Мы записываем ответы локально, параллельно отправляя на бек информацию о ответах
|
||||||
const answers = useQuizViewStore((state) => state.answers);
|
const answers = useQuizViewStore((state) => state.answers);
|
||||||
@ -96,6 +105,19 @@ export function useAIQuiz() {
|
|||||||
nextQuestion: questions[quizStep - 1],
|
nextQuestion: questions[quizStep - 1],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Таймер авто-перехода между вопросами (AI)
|
||||||
|
useQuestionTimer({
|
||||||
|
enabled: Boolean(settings.questionTimerEnabled),
|
||||||
|
seconds: settings.cfg.time_of_passing ?? 0,
|
||||||
|
quizId,
|
||||||
|
preview,
|
||||||
|
currentQuestion,
|
||||||
|
onNext: () => {
|
||||||
|
console.log("🤖 AI Quiz: Timer triggered moveToNextQuestion");
|
||||||
|
moveToNextQuestion();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
currentQuestion,
|
currentQuestion,
|
||||||
currentQuestionStepNumber: null,
|
currentQuestionStepNumber: null,
|
||||||
|
|||||||
@ -9,10 +9,20 @@ import { useQuizViewStore } from "@stores/quizView";
|
|||||||
|
|
||||||
import { useVkMetricsGoals } from "@/utils/hooks/metrics/useVkMetricsGoals";
|
import { useVkMetricsGoals } from "@/utils/hooks/metrics/useVkMetricsGoals";
|
||||||
import { useYandexMetricsGoals } from "@/utils/hooks/metrics/useYandexMetricsGoals";
|
import { useYandexMetricsGoals } from "@/utils/hooks/metrics/useYandexMetricsGoals";
|
||||||
|
import { useQuestionTimer } from "./useQuestionTimer";
|
||||||
|
|
||||||
export function useBranchingQuiz() {
|
export function useBranchingQuiz() {
|
||||||
|
console.log("РАБОТАЮ Я, УПРАВЛЯТОР КВИЗА ВЕТВЛЕНИЯ");
|
||||||
//Получаем инфо о квизе и список вопросов.
|
//Получаем инфо о квизе и список вопросов.
|
||||||
const { settings, questions, quizId, cnt } = useQuizStore();
|
const { settings, questions, quizId, cnt, preview } = useQuizStore();
|
||||||
|
|
||||||
|
// Отладочная информация о настройках таймера
|
||||||
|
console.log("🌳 Branching Quiz settings:", {
|
||||||
|
questionTimerEnabled: settings.questionTimerEnabled,
|
||||||
|
time_of_passing: settings.cfg.time_of_passing,
|
||||||
|
timerEnabled: Boolean(settings.questionTimerEnabled),
|
||||||
|
timerSeconds: settings.cfg.time_of_passing ?? 0,
|
||||||
|
});
|
||||||
|
|
||||||
//Когда квиз линейный, не ветвящийся, мы идём по вопросам по их порядковому номеру. Это их page.
|
//Когда квиз линейный, не ветвящийся, мы идём по вопросам по их порядковому номеру. Это их page.
|
||||||
//За корректность page отвечает конструктор квизов. Интересный факт, если в конструкторе удалить из середины вопрос, то случится куча запросов изменения вопросов с изменением этого page
|
//За корректность page отвечает конструктор квизов. Интересный факт, если в конструкторе удалить из середины вопрос, то случится куча запросов изменения вопросов с изменением этого page
|
||||||
@ -48,12 +58,17 @@ export function useBranchingQuiz() {
|
|||||||
if (settings.cfg.haveRoot) {
|
if (settings.cfg.haveRoot) {
|
||||||
// Если есть ветвление, то settings.cfg.haveRoot будет заполнен
|
// Если есть ветвление, то settings.cfg.haveRoot будет заполнен
|
||||||
//Если заполнен, то дерево растёт с root и это 1 вопрос :)
|
//Если заполнен, то дерево растёт с root и это 1 вопрос :)
|
||||||
|
console.log("Существует запись о корне: " + settings.cfg.haveRoot);
|
||||||
const nextQuestion = sortedQuestions.find(
|
const nextQuestion = sortedQuestions.find(
|
||||||
//Функция ищет первое совпадение по массиву
|
//Функция ищет первое совпадение по массиву
|
||||||
(question) => question.id === settings.cfg.haveRoot || question.content.id === settings.cfg.haveRoot
|
(question) => question.id === settings.cfg.haveRoot || question.content.id === settings.cfg.haveRoot
|
||||||
);
|
);
|
||||||
if (!nextQuestion) return null;
|
|
||||||
|
|
||||||
|
console.log("___nextQuestion____");
|
||||||
|
console.log(nextQuestion);
|
||||||
|
console.log("___sortedQuestions____");
|
||||||
|
console.log(sortedQuestions);
|
||||||
|
if (!nextQuestion) return null;
|
||||||
return nextQuestion.id;
|
return nextQuestion.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -166,6 +181,19 @@ export function useBranchingQuiz() {
|
|||||||
return next;
|
return next;
|
||||||
}, [nextQuestionId, findResultPointsLogic, linearQuestionIndex, sortedQuestions, settings.cfg.score]);
|
}, [nextQuestionId, findResultPointsLogic, linearQuestionIndex, sortedQuestions, settings.cfg.score]);
|
||||||
|
|
||||||
|
// Таймер авто-перехода между вопросами
|
||||||
|
useQuestionTimer({
|
||||||
|
enabled: Boolean(settings.questionTimerEnabled),
|
||||||
|
seconds: settings.cfg.time_of_passing ?? 0,
|
||||||
|
quizId,
|
||||||
|
preview,
|
||||||
|
currentQuestion,
|
||||||
|
onNext: () => {
|
||||||
|
console.log("🌳 Branching Quiz: Timer triggered moveToNextQuestion");
|
||||||
|
moveToNextQuestion();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
//Показать визуалом юзеру результат
|
//Показать визуалом юзеру результат
|
||||||
const showResult = useCallback(() => {
|
const showResult = useCallback(() => {
|
||||||
if (nextQuestion?.type !== "result") throw new Error("Current question is not result");
|
if (nextQuestion?.type !== "result") throw new Error("Current question is not result");
|
||||||
|
|||||||
@ -9,10 +9,19 @@ import { useQuizViewStore } from "@stores/quizView";
|
|||||||
|
|
||||||
import { useVkMetricsGoals } from "@/utils/hooks/metrics/useVkMetricsGoals";
|
import { useVkMetricsGoals } from "@/utils/hooks/metrics/useVkMetricsGoals";
|
||||||
import { useYandexMetricsGoals } from "@/utils/hooks/metrics/useYandexMetricsGoals";
|
import { useYandexMetricsGoals } from "@/utils/hooks/metrics/useYandexMetricsGoals";
|
||||||
|
import { useQuestionTimer } from "./useQuestionTimer";
|
||||||
|
|
||||||
export function useLinearQuiz() {
|
export function useLinearQuiz() {
|
||||||
//Получаем инфо о квизе и список вопросов.
|
//Получаем инфо о квизе и список вопросов.
|
||||||
const { settings, questions, quizId, cnt } = useQuizStore();
|
const { settings, questions, quizId, cnt, preview } = useQuizStore();
|
||||||
|
|
||||||
|
// Отладочная информация о настройках таймера
|
||||||
|
console.log("📏 Linear Quiz settings:", {
|
||||||
|
questionTimerEnabled: settings.questionTimerEnabled,
|
||||||
|
time_of_passing: settings.cfg.time_of_passing,
|
||||||
|
timerEnabled: Boolean(settings.questionTimerEnabled),
|
||||||
|
timerSeconds: settings.cfg.time_of_passing ?? 0,
|
||||||
|
});
|
||||||
|
|
||||||
//Когда квиз линейный, не ветвящийся, мы идём по вопросам по их порядковому номеру. Это их page.
|
//Когда квиз линейный, не ветвящийся, мы идём по вопросам по их порядковому номеру. Это их page.
|
||||||
//За корректность page отвечает конструктор квизов. Интересный факт, если в конструкторе удалить из середины вопрос, то случится куча запросов изменения вопросов с изменением этого page
|
//За корректность page отвечает конструктор квизов. Интересный факт, если в конструкторе удалить из середины вопрос, то случится куча запросов изменения вопросов с изменением этого page
|
||||||
@ -166,6 +175,20 @@ export function useLinearQuiz() {
|
|||||||
return next;
|
return next;
|
||||||
}, [nextQuestionId, findResultPointsLogic, linearQuestionIndex, sortedQuestions, settings.cfg.score]);
|
}, [nextQuestionId, findResultPointsLogic, linearQuestionIndex, sortedQuestions, settings.cfg.score]);
|
||||||
|
|
||||||
|
// Таймер авто-перехода между вопросами
|
||||||
|
useQuestionTimer({
|
||||||
|
enabled: Boolean(settings.questionTimerEnabled),
|
||||||
|
seconds: settings.cfg.time_of_passing ?? 0,
|
||||||
|
quizId,
|
||||||
|
preview,
|
||||||
|
currentQuestion,
|
||||||
|
onNext: () => {
|
||||||
|
console.log("📏 Linear Quiz: Timer triggered moveToNextQuestion");
|
||||||
|
// Программный переход к следующему вопросу
|
||||||
|
moveToNextQuestion();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
//Показать визуалом юзеру результат
|
//Показать визуалом юзеру результат
|
||||||
const showResult = useCallback(() => {
|
const showResult = useCallback(() => {
|
||||||
if (nextQuestion?.type !== "result") throw new Error("Current question is not result");
|
if (nextQuestion?.type !== "result") throw new Error("Current question is not result");
|
||||||
|
|||||||
105
lib/utils/hooks/FlowControlLogic/useQuestionTimer.ts
Normal file
105
lib/utils/hooks/FlowControlLogic/useQuestionTimer.ts
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import { enqueueSnackbar } from "notistack";
|
||||||
|
|
||||||
|
import { useQuizViewStore } from "@/stores/quizView";
|
||||||
|
import { sendQuestionAnswer } from "@/utils/sendQuestionAnswer";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
enabled: boolean;
|
||||||
|
seconds: number;
|
||||||
|
quizId: string;
|
||||||
|
preview: boolean;
|
||||||
|
currentQuestion: any; // Using any to avoid tight coupling with question union types
|
||||||
|
onNext: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useQuestionTimer({ enabled, seconds, quizId, preview, currentQuestion, onNext }: Params) {
|
||||||
|
const ownVariants = useQuizViewStore((state) => state.ownVariants);
|
||||||
|
const currentQuizStep = useQuizViewStore((state) => state.currentQuizStep);
|
||||||
|
const timeoutRef = useRef<number | null>(null);
|
||||||
|
const isFirstQuestionRef = useRef<boolean>(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
console.log("🕐 useQuestionTimer useEffect triggered", {
|
||||||
|
enabled,
|
||||||
|
seconds,
|
||||||
|
quizId,
|
||||||
|
preview,
|
||||||
|
currentQuestionId: currentQuestion?.id,
|
||||||
|
currentQuestionType: currentQuestion?.type,
|
||||||
|
currentQuizStep,
|
||||||
|
hasCurrentQuestion: !!currentQuestion,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!enabled) {
|
||||||
|
console.log("❌ Timer disabled");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!seconds || seconds <= 0) {
|
||||||
|
console.log("❌ Invalid seconds:", seconds);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!currentQuestion) {
|
||||||
|
console.log("❌ No current question");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (currentQuizStep !== "question") {
|
||||||
|
console.log("❌ Not on question step:", currentQuizStep);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (currentQuestion.type === "result") {
|
||||||
|
console.log("❌ Question is result type");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("✅ Starting timer for", seconds, "seconds");
|
||||||
|
|
||||||
|
// Сбрасываем предыдущий таймер
|
||||||
|
if (timeoutRef.current) {
|
||||||
|
console.log("🔄 Clearing previous timer");
|
||||||
|
clearTimeout(timeoutRef.current);
|
||||||
|
timeoutRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Для первого вопроса добавляем дополнительную задержку, чтобы избежать конфликтов с навигацией
|
||||||
|
const isFirstQuestion = isFirstQuestionRef.current;
|
||||||
|
const startDelay = isFirstQuestion ? 2000 : 100; // 2 секунды для первого вопроса, 100ms для остальных
|
||||||
|
|
||||||
|
if (isFirstQuestion) {
|
||||||
|
console.log("🔄 First question detected, adding 2s delay to prevent navigation conflicts");
|
||||||
|
isFirstQuestionRef.current = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
timeoutRef.current = window.setTimeout(
|
||||||
|
async () => {
|
||||||
|
console.log("⏰ Timer expired! Auto-advancing to next question");
|
||||||
|
try {
|
||||||
|
if (!preview) {
|
||||||
|
console.log("📤 Sending empty answer for question:", currentQuestion.id);
|
||||||
|
// Отправляем пустую строку в ответе (questionAnswer === undefined)
|
||||||
|
await sendQuestionAnswer(quizId, currentQuestion, undefined, ownVariants);
|
||||||
|
console.log("✅ Empty answer sent successfully");
|
||||||
|
} else {
|
||||||
|
console.log("👀 Preview mode - skipping answer send");
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("❌ Error sending empty timed answer", e);
|
||||||
|
enqueueSnackbar("Ошибка при отправке ответа по таймеру");
|
||||||
|
} finally {
|
||||||
|
console.log("➡️ Calling onNext()");
|
||||||
|
onNext();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
seconds * 1000 + startDelay
|
||||||
|
);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
console.log("🧹 Cleaning up timer");
|
||||||
|
if (timeoutRef.current) {
|
||||||
|
clearTimeout(timeoutRef.current);
|
||||||
|
timeoutRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [enabled, seconds, quizId, preview, currentQuestion?.id, currentQuizStep, onNext]);
|
||||||
|
}
|
||||||
@ -2,6 +2,7 @@ import { useBranchingQuiz } from "./FlowControlLogic/useBranchingQuiz";
|
|||||||
import { useLinearQuiz } from "./FlowControlLogic/useLinearQuiz";
|
import { useLinearQuiz } from "./FlowControlLogic/useLinearQuiz";
|
||||||
import { useAIQuiz } from "./FlowControlLogic/useAIQuiz";
|
import { useAIQuiz } from "./FlowControlLogic/useAIQuiz";
|
||||||
import { Status } from "@/model/settingsData";
|
import { Status } from "@/model/settingsData";
|
||||||
|
import { useQuizStore } from "@/stores/useQuizStore";
|
||||||
|
|
||||||
interface StatusData {
|
interface StatusData {
|
||||||
status: Status;
|
status: Status;
|
||||||
@ -11,6 +12,7 @@ interface StatusData {
|
|||||||
// выбор способа управления в зависимости от статуса
|
// выбор способа управления в зависимости от статуса
|
||||||
let cachedManager: () => ReturnType<typeof useLinearQuiz>;
|
let cachedManager: () => ReturnType<typeof useLinearQuiz>;
|
||||||
export let statusOfQuiz: "line" | "branch" | "ai";
|
export let statusOfQuiz: "line" | "branch" | "ai";
|
||||||
|
let isInitialized = false;
|
||||||
|
|
||||||
function analyicStatus({ status, haveRoot }: StatusData) {
|
function analyicStatus({ status, haveRoot }: StatusData) {
|
||||||
if (status === "ai") {
|
if (status === "ai") {
|
||||||
@ -27,6 +29,7 @@ function analyicStatus({ status, haveRoot }: StatusData) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const initDataManager = (data: StatusData) => {
|
export const initDataManager = (data: StatusData) => {
|
||||||
|
console.log("🔧 Initializing DataManager with:", data);
|
||||||
analyicStatus(data);
|
analyicStatus(data);
|
||||||
switch (statusOfQuiz) {
|
switch (statusOfQuiz) {
|
||||||
case "line":
|
case "line":
|
||||||
@ -39,12 +42,32 @@ export const initDataManager = (data: StatusData) => {
|
|||||||
cachedManager = useAIQuiz;
|
cachedManager = useAIQuiz;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
isInitialized = true;
|
||||||
|
console.log("✅ DataManager initialized with type:", statusOfQuiz);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Главный хук (интерфейс для потребителей)
|
// Главный хук (интерфейс для потребителей)
|
||||||
export const useQuestionFlowControl = () => {
|
export const useQuestionFlowControl = () => {
|
||||||
if (!cachedManager) {
|
if (!cachedManager || !isInitialized) {
|
||||||
|
// Попытка автоматической инициализации на основе текущих настроек
|
||||||
|
const { settings } = useQuizStore.getState();
|
||||||
|
if (settings && settings.status) {
|
||||||
|
console.log("🔄 Auto-initializing DataManager with settings:", settings);
|
||||||
|
initDataManager({
|
||||||
|
status: settings.status,
|
||||||
|
haveRoot: settings.cfg.haveRoot,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
throw new Error("DataManager not initialized! Call initDataManager() first.");
|
throw new Error("DataManager not initialized! Call initDataManager() first.");
|
||||||
}
|
}
|
||||||
|
}
|
||||||
return cachedManager();
|
return cachedManager();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Функция для сброса состояния (полезна для HMR)
|
||||||
|
export const resetDataManager = () => {
|
||||||
|
console.log("🔄 Resetting DataManager");
|
||||||
|
cachedManager = null as any;
|
||||||
|
isInitialized = false;
|
||||||
|
statusOfQuiz = null as any;
|
||||||
|
};
|
||||||
|
|||||||
@ -11,6 +11,7 @@ export async function sendQuestionAnswer(
|
|||||||
ownVariants: OwnVariant[]
|
ownVariants: OwnVariant[]
|
||||||
) {
|
) {
|
||||||
if (!questionAnswer) {
|
if (!questionAnswer) {
|
||||||
|
console.log("📤 sendQuestionAnswer: Sending empty answer for question", question.id);
|
||||||
return sendAnswer({
|
return sendAnswer({
|
||||||
questionId: question.id,
|
questionId: question.id,
|
||||||
body: "",
|
body: "",
|
||||||
|
|||||||
825
package-lock.json
generated
825
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -26,8 +26,9 @@
|
|||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"cypress:open": "cypress open",
|
"cypress:open": "cypress open",
|
||||||
"prepublishOnly": "npm run build:package",
|
"prepublishOnly": "npm run build:package",
|
||||||
"deploy": "docker login gitea.pena && docker build -t gitea.pena/squiz/frontanswerer/$(git branch --show-current):latest . && docker push gitea.pena/squiz/frontanswerer/$(git branch --show-current):latest",
|
"deploy": "echo '🚀 Начало процесса деплоя...' && docker login gitea.pena && if [ $? -eq 0 ]; then echo '✅ Успешный логин в Docker registry'; else echo '❌ Ошибка логина в Docker registry'; exit 1; fi && echo '🏗️ Сборка Docker образа...' && docker build -t gitea.pena/squiz/frontanswerer/$(git branch --show-current):latest . && if [ $? -eq 0 ]; then echo '✅ Docker образ успешно собран'; else echo '❌ Ошибка сборки Docker образа'; exit 1; fi && echo '📤 Пуш образа в registry...' && docker push gitea.pena/squiz/frontanswerer/$(git branch --show-current):latest && if [ $? -eq 0 ]; then echo '✅ Образ успешно загружен в registry'; echo '🎉 Деплой завершен успешно!'; else echo '❌ Ошибка загрузки образа в registry'; exit 1; fi",
|
||||||
"prepare": "husky"
|
"prepare": "husky",
|
||||||
|
"test": "cypress open"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@emoji-mart/data": "^1.1.2",
|
"@emoji-mart/data": "^1.1.2",
|
||||||
@ -37,7 +38,8 @@
|
|||||||
"@mui/icons-material": "^5.10.14",
|
"@mui/icons-material": "^5.10.14",
|
||||||
"@mui/material": "^5.10.14",
|
"@mui/material": "^5.10.14",
|
||||||
"@mui/x-date-pickers": "^6.16.1",
|
"@mui/x-date-pickers": "^6.16.1",
|
||||||
"@types/node": "^16.7.13",
|
"@types/mocha": "^10.0.10",
|
||||||
|
"@types/node": "^20.19.19",
|
||||||
"@types/react": "^18.2.43",
|
"@types/react": "^18.2.43",
|
||||||
"@types/react-dom": "^18.2.17",
|
"@types/react-dom": "^18.2.17",
|
||||||
"@types/react-helmet": "^6.1.11",
|
"@types/react-helmet": "^6.1.11",
|
||||||
@ -53,6 +55,7 @@
|
|||||||
"husky": "^9.0.11",
|
"husky": "^9.0.11",
|
||||||
"immer": "^10.0.3",
|
"immer": "^10.0.3",
|
||||||
"lint-staged": "^15.2.5",
|
"lint-staged": "^15.2.5",
|
||||||
|
"mocha": "^11.7.3",
|
||||||
"moment": "^2.30.1",
|
"moment": "^2.30.1",
|
||||||
"nanoid": "^5.0.3",
|
"nanoid": "^5.0.3",
|
||||||
"notistack": "^3.0.1",
|
"notistack": "^3.0.1",
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user