Merge branch 'staging'
Some checks failed
Deploy / DeployService (push) Successful in 22s
Deploy / CreateImage (push) Has been cancelled

This commit is contained in:
Nastya 2025-06-18 00:24:08 +03:00 committed by skeris
commit 89b55d4d3e
55 changed files with 4557 additions and 3474 deletions

@ -17,7 +17,7 @@ jobs:
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
DeployService:
runs-on: [frontprod]
needs: CreateImage
# needs: CreateImage
uses: https://gitea.pena/PenaDevops/actions.git/.gitea/workflows/deploy.yml@v1.1.4-p7
with:
runner: hubprod

@ -8,10 +8,10 @@ on:
jobs:
CreateImage:
runs-on: [hubstaging]
runs-on: [skeris]
uses: http://gitea.pena/PenaDevops/actions.git/.gitea/workflows/build-image.yml@v1.1.6-p
with:
runner: hubstaging
runner: skeris
secrets:
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}

@ -1,4 +1,4 @@
FROM gitea.pena/penadevops/container-images/node:v20.14.0 as build
FROM gitea.pena/penadevops/container-images/node:main as build
WORKDIR /usr/app
COPY . .

577
api-docs.html Normal file

@ -0,0 +1,577 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>QUIZ Service API Documentation</title>
<style>
:root {
--primary-color: #7E2AEA;
--text-color: #333;
--bg-color: #fff;
--border-color: #e0e0e0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
line-height: 1.6;
color: var(--text-color);
margin: 0;
padding: 0;
background: var(--bg-color);
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}
header {
background: var(--primary-color);
color: white;
padding: 2rem 0;
margin-bottom: 2rem;
}
h1, h2, h3 {
color: var(--primary-color);
}
.endpoint {
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 20px;
margin-bottom: 20px;
background: #fafafa;
}
.method {
display: inline-block;
padding: 4px 8px;
border-radius: 4px;
color: white;
font-weight: bold;
margin-right: 10px;
}
.get { background: #61affe; }
.post { background: #49cc90; }
.put { background: #fca130; }
.delete { background: #f93e3e; }
.patch { background: #50e3c2; }
.schema {
background: #f8f9fa;
padding: 15px;
border-radius: 4px;
margin: 10px 0;
}
.nav {
position: sticky;
top: 0;
background: white;
padding: 1rem;
border-bottom: 1px solid var(--border-color);
}
.nav a {
color: var(--primary-color);
text-decoration: none;
margin-right: 20px;
}
.nav a:hover {
text-decoration: underline;
}
code {
background: #f1f1f1;
padding: 2px 4px;
border-radius: 4px;
font-family: 'Courier New', Courier, monospace;
}
.response {
margin-top: 10px;
padding: 10px;
background: #f8f9fa;
border-left: 4px solid var(--primary-color);
}
.components {
margin: 2rem 0;
padding: 1rem;
background: #f8f9fa;
border-radius: 8px;
}
.security {
background: #fff3cd;
padding: 1rem;
border-radius: 4px;
margin: 1rem 0;
}
.parameter {
margin: 0.5rem 0;
padding: 0.5rem;
background: #f8f9fa;
border-radius: 4px;
}
.parameter.required {
border-left: 4px solid #dc3545;
}
.enum-values {
color: #666;
font-style: italic;
}
</style>
</head>
<body>
<header>
<div class="container">
<h1>QUIZ Service API Documentation</h1>
<p>Version 1.0.0</p>
</div>
</header>
<nav class="nav">
<div class="container">
<a href="#components">Components</a>
<a href="#quiz">Quiz Endpoints</a>
<a href="#question">Question Endpoints</a>
<a href="#results">Results Endpoints</a>
<a href="#statistics">Statistics Endpoints</a>
<a href="#account">Account Endpoints</a>
</div>
</nav>
<main class="container">
<section id="components">
<h2>Components</h2>
<div class="components">
<h3>Quiz Model</h3>
<div class="schema">
<pre><code>{
"id": integer, // Id of created quiz
"qid": string, // string id for customers
"deleted": boolean, // true if quiz deleted
"archived": boolean, // true if quiz archived
"fingerprinting": boolean, // set true for save deviceId
"repeatable": boolean, // set true for allow user to repeat quiz
"note_prevented": boolean, // set true for save statistic of incomplete quiz passing
"mail_notifications": boolean, // set true for mail notification for each quiz passing
"unique_answers": boolean, // set true for save statistics only for unique quiz passing
"name": string, // name of quiz. max 280 length
"description": string, // description of quiz
"config": string, // config of quiz. serialized json for rules of quiz flow
"status": string, // status of quiz. allow only '', 'draft', 'template', 'stop', 'start'
"limit": integer, // limit is count of max quiz passing
"due_to": integer, // last time when quiz is valid. timestamp in seconds
"time_of_passing": integer, // seconds to pass quiz
"pausable": boolean, // true if it is allowed for pause quiz
"version": integer, // version of quiz
"version_comment": string, // version comment to version of quiz
"parent_ids": integer[], // array of previous versions of quiz
"created_at": string, // time of creating
"updated_at": string, // time of last updating
"question_cnt": integer, // count of questions
"passed_count": integer, // count passings
"average_time": integer, // average time of passing
"super": boolean, // set true if squiz realize group functionality
"group_id": integer // group of new quiz
}</code></pre>
</div>
</div>
<div class="components">
<h3>Question Model</h3>
<div class="schema">
<pre><code>{
"id": integer, // Id of created question
"quiz_id": integer, // relation to quiz
"title": string, // title of question. max 512 length
"description": string, // description of question
"type": string, // status of question. allow only text, select, file, variant, images, varimg, emoji, date, number, page, rating
"required": boolean, // user must pass this question
"deleted": boolean, // true if question is deleted
"page": integer, // page if question
"content": string, // serialized json of created question
"version": integer, // version of quiz
"parent_ids": integer[], // array of previous versions of quiz
"created_at": string, // time of creating
"updated_at": string // time of last updating
}</code></pre>
</div>
</div>
<div class="components">
<h3>Answer Model</h3>
<div class="schema">
<pre><code>{
"Id": integer, // id ответа
"Content": string, // контент ответа
"QuestionId": integer, // id вопроса к которому ответ
"QuizId": integer, // id опроса к которому ответ
"Fingerprint": string, // fingerprint
"Session": string, // сессия
"Result": boolean, // true or false?
"CreatedAt": string, // таймшап когда ответ создан
"New": boolean, // новый ответ?
"Deleted": boolean // удален?
}</code></pre>
</div>
</div>
<div class="components">
<h3>LeadTarget Model</h3>
<div class="schema">
<pre><code>{
"ID": integer, // primary key
"AccountID": string, // account identifier
"Type": string, // type of target (mail, telegram, whatsapp)
"QuizID": integer, // ID of the quiz
"Target": string, // target address
"InviteLink": string, // invitation link
"Deleted": boolean, // is deleted
"CreatedAt": string // creation timestamp
}</code></pre>
</div>
</div>
<div class="components">
<h3>TgAccount Model</h3>
<div class="schema">
<pre><code>{
"ID": integer, // primary key
"ApiID": integer, // Telegram API ID
"ApiHash": string, // Telegram API Hash
"PhoneNumber": string, // phone number
"Password": string, // account password
"Status": string, // account status (active, inactive, ban)
"Deleted": boolean, // is deleted
"CreatedAt": string // creation timestamp
}</code></pre>
</div>
</div>
</section>
<section id="quiz">
<h2>Quiz Endpoints</h2>
<div class="endpoint">
<h3>Create Quiz</h3>
<span class="method post">POST</span>
<code>/quiz/create</code>
<p>Create a new quiz with specified parameters.</p>
<div class="security">
<h4>Security</h4>
<p>This endpoint requires authentication.</p>
</div>
<h4>Request Body:</h4>
<div class="schema">
<pre><code>{
"fingerprinting": boolean, // set true for save deviceId
"repeatable": boolean, // set true for allow user to repeat quiz
"note_prevented": boolean, // set true for save statistic of incomplete quiz passing
"mail_notifications": boolean, // set true for mail notification for each quiz passing
"unique_answers": boolean, // set true for save statistics only for unique quiz passing
"name": string, // name of quiz. max 280 length
"description": string, // description of quiz
"config": string, // config of quiz. serialized json for rules of quiz flow
"status": string, // status of quiz. allow only '', 'draft', 'template', 'stop', 'start'
"limit": integer, // limit is count of max quiz passing
"due_to": integer, // last time when quiz is valid. timestamp in seconds
"time_of_passing": integer, // seconds to pass quiz
"pausable": boolean, // true if it is allowed for pause quiz
"question_cnt": integer, // count of questions
"super": boolean, // set true if squiz realize group functionality
"group_id": integer // group of new quiz
}</code></pre>
</div>
<h4>Responses:</h4>
<div class="response">
<h5>201 Created</h5>
<p>Quiz successfully created. Returns the created quiz object.</p>
<div class="schema">
<pre><code>{
"id": integer,
"qid": string,
"name": string,
"description": string,
// ... other quiz properties
}</code></pre>
</div>
</div>
<div class="response">
<h5>422 Unprocessable Entity</h5>
<p>Name field should have less than 280 characters.</p>
</div>
<div class="response">
<h5>406 Not Acceptable</h5>
<p>Status on creating must be only draft, template, stop, start or due to time must be lesser than now.</p>
<div class="enum-values">
Allowed status values: '', 'draft', 'template', 'stop', 'start'
</div>
</div>
<div class="response">
<h5>409 Conflict</h5>
<p>You can pause quiz only if it has deadline for passing.</p>
</div>
<div class="response">
<h5>500 Internal Server Error</h5>
<p>If you get any content string send it to developer.</p>
</div>
</div>
<div class="endpoint">
<h3>Get Quiz List</h3>
<span class="method post">POST</span>
<code>/quiz/getList</code>
<p>Get paginated list of quizzes with filtering options.</p>
<h4>Request Body:</h4>
<div class="schema">
<pre><code>{
"limit": integer,
"offset": integer,
"from": integer,
"to": integer,
"search": string,
"status": string,
"deleted": boolean,
"archived": boolean,
"super": boolean,
"group_id": integer
}</code></pre>
</div>
<h4>Responses:</h4>
<div class="response">
<h5>200 OK</h5>
<p>Returns list of quizzes with total count.</p>
<div class="schema">
<pre><code>{
"count": integer,
"items": [
{
"id": integer,
"qid": string,
// ... other quiz properties
}
]
}</code></pre>
</div>
</div>
<div class="response">
<h5>406 Not Acceptable</h5>
<p>Inappropriate status, allowed only '', 'stop', 'start', 'draft', 'template', 'timeout', 'offlimit'.</p>
</div>
<div class="response">
<h5>500 Internal Server Error</h5>
<p>If you get any content string send it to developer.</p>
</div>
</div>
<!-- Add more quiz endpoints -->
</section>
<section id="question">
<h2>Question Endpoints</h2>
<div class="endpoint">
<h3>Create Question</h3>
<span class="method post">POST</span>
<code>/question/create</code>
<p>Create a new question for a quiz.</p>
</div>
<!-- Add more question endpoints -->
</section>
<section id="results">
<h2>Results Endpoints</h2>
<div class="endpoint">
<h3>Get Quiz Results</h3>
<span class="method post">POST</span>
<code>/results/getResults/{quizId}</code>
<p>Get list of quiz results with pagination.</p>
<h4>Path Parameters:</h4>
<div class="parameter">
<code>quizId</code> - ID of the quiz to get results for
</div>
<h4>Request Body:</h4>
<div class="schema">
<pre><code>{
"to": integer, // таймштамп времени, до которого выбирать статистику. если 0 или не передано - этого ограничения нет
"from": integer, // таймштамп времени, после которого выбирать статистику. если 0 или не передано - этого ограничения нет
"new": boolean, // флаг, по которому вернутся только новые результаты, ещё не просмотренные пользователем
"page": integer, // номер страницы для пагинации
"limit": integer // размер страницы для пагинации
}</code></pre>
</div>
<h4>Responses:</h4>
<div class="response">
<h5>200 OK</h5>
<p>Returns paginated list of results.</p>
<div class="schema">
<pre><code>{
"total_count": integer, // общее количество элементов удволетворяющее фильтру
"results": [
{
"content": string, // содержимое ответа
"id": integer, // айдишник ответа
"new": boolean, // статус, был ли просмотрен ответ
"created_at": string // время создания этого результата
}
]
}</code></pre>
</div>
</div>
</div>
<div class="endpoint">
<h3>Export Results</h3>
<span class="method post">POST</span>
<code>/results/{quizID}/export</code>
<p>Export quiz results to CSV format.</p>
<h4>Path Parameters:</h4>
<div class="parameter required">
<code>quizID</code> - ID of the quiz to export results from
</div>
<h4>Request Body:</h4>
<div class="schema">
<pre><code>{
"to": string, // Дата окончания диапазона времени для экспорта результатов
"from": string, // Дата начала временного диапазона для экспорта результатов
"new": boolean // Экспортировать ли только новые результаты?
}</code></pre>
</div>
<h4>Responses:</h4>
<div class="response">
<h5>200 OK</h5>
<p>Returns CSV file with quiz results.</p>
<div class="schema">
<pre><code>Content-Type: text/csv</code></pre>
</div>
</div>
</div>
</section>
<section id="telegram">
<h2>Telegram Endpoints</h2>
<div class="endpoint">
<h3>Create Telegram Account</h3>
<span class="method post">POST</span>
<code>/telegram/create</code>
<p>Authorize server in Telegram account.</p>
<h4>Request Body:</h4>
<div class="schema">
<pre><code>{
"ApiID": integer, // Telegram API ID
"ApiHash": string, // Telegram API Hash
"PhoneNumber": string, // Phone number
"Password": string // Account password
}</code></pre>
</div>
<h4>Responses:</h4>
<div class="response">
<h5>200 OK</h5>
<p>Returns signature for code verification.</p>
<div class="schema">
<pre><code>{
"signature": string // Session identifier for code verification
}</code></pre>
</div>
</div>
<div class="response">
<h5>409 Conflict</h5>
<p>Account already exists and is active.</p>
</div>
</div>
</section>
<section id="audience">
<h2>Audience Endpoints</h2>
<div class="endpoint">
<h3>Create Quiz Audience</h3>
<span class="method post">POST</span>
<code>/quiz/{quizID}/auditory</code>
<p>Create audience for a quiz.</p>
<h4>Path Parameters:</h4>
<div class="parameter required">
<code>quizID</code> - ID of the quiz
</div>
<h4>Responses:</h4>
<div class="response">
<h5>200 OK</h5>
<p>Returns ID of created audience.</p>
<div class="schema">
<pre><code>{
"id": integer // ID of created auditory
}</code></pre>
</div>
</div>
</div>
</section>
<section id="statistics">
<h2>Statistics Endpoints</h2>
<div class="endpoint">
<h3>Get Question Statistics</h3>
<span class="method post">POST</span>
<code>/statistic/{quizID}/questions</code>
<p>Get statistics for specific questions in a quiz.</p>
</div>
<!-- Add more statistics endpoints -->
</section>
<section id="account">
<h2>Account Endpoints</h2>
<div class="endpoint">
<h3>Add Lead Target</h3>
<span class="method post">POST</span>
<code>/account/leadtarget</code>
<p>Add a target destination for lead notifications.</p>
</div>
<!-- Add more account endpoints -->
</section>
</main>
<footer class="container">
<p>© 2024 QUIZ Service API Documentation</p>
</footer>
</body>
</html>

@ -3,8 +3,23 @@ import { defineConfig } from "cypress";
export default defineConfig({
e2e: {
baseUrl: 'http://localhost:3000',
viewportWidth: 1440,
viewportHeight: 900,
supportFile: false,
viewportWidth: 1280,
viewportHeight: 720,
video: true,
screenshotOnRunFailure: true,
supportFile: 'cypress/support/e2e.ts',
defaultCommandTimeout: 10000,
pageLoadTimeout: 30000,
requestTimeout: 10000,
responseTimeout: 30000,
setupNodeEvents(on, config) {
// implement node event listeners here
},
},
component: {
devServer: {
framework: 'react',
bundler: 'vite',
},
},
});

@ -0,0 +1,75 @@
/// <reference types="cypress" />
describe('Personalization Flow', () => {
beforeEach(() => {
// Логинимся перед каждым тестом
cy.login();
});
it('should complete personalization flow and open link in new tab', () => {
// Ищем нужный квиз и нажимаем редактировать
cy.contains('Сочетание перестановки и размещения')
.parent()
.parent()
.contains('Редактировать')
.click();
// Переходим на вкладку персонализации
cy.contains('Персонализация').click();
// Ждем загрузки данных
cy.get('.MuiSkeleton-root', { timeout: 30000 }).should('not.exist');
cy.wait(6000);
// Удаляем все существующие ссылки
cy.get('body').then(($body) => {
if ($body.find('.delete_aud').length > 0) {
// Пока есть кнопки удаления - удаляем ссылки
const deleteLinks = () => {
// Находим первую кнопку удаления и кликаем по ней
cy.get('.delete_aud').first().click();
// Подтверждаем удаление
cy.get('#delete_OK').click();
// Проверяем, остались ли еще кнопки удаления
cy.get('body').then(($body) => {
if ($body.find('.delete_aud').length > 0) {
deleteLinks();
}
});
};
deleteLinks();
}
});
// Выбираем пол (М)
cy.contains('М').click();
// Генерируем случайный возраст от 1 до 99
const randomAge = Math.floor(Math.random() * 99) + 1;
// Вводим возраст
cy.get('input[placeholder="Введите возраст"]')
.type(randomAge.toString())
.should('have.value', randomAge.toString());
// Нажимаем кнопку Ок
cy.contains('Ок').click();
// Ждем появления ссылки и получаем её текст
cy.get('.link', { timeout: 30000 })
.should('be.visible')
.invoke('text')
.then((text) => {
// Исправляем домен в ссылке
const url = new URL(text);
url.hostname = 's.hbpn.link';
const correctUrl = url.toString();
// Переходим на страницу по исправленной ссылке
cy.visit(correctUrl);
// Проверяем содержимое страницы
cy.contains('Сочетание перестановки и размещения').should('exist');
});
});
});

@ -0,0 +1,28 @@
/// <reference types="cypress" />
declare global {
namespace Cypress {
interface Chainable {
login(): Chainable<void>
}
}
}
Cypress.Commands.add('login', () => {
// Пробуем перейти на страницу входа
cy.visit('/signin', {
timeout: 10000, // Увеличиваем таймаут до 10 секунд
failOnStatusCode: false // Не падаем при ошибках статуса
});
// Проверяем, что мы на странице входа
cy.url().should('include', '/signin');
// Вводим данные для входа
cy.get('#email', { timeout: 10000 }).should('be.visible').type('test@test.ru');
cy.get('#password', { timeout: 10000 }).should('be.visible').type('testtest');
cy.get('button[type="submit"]', { timeout: 10000 }).should('be.visible').click();
// Ждем успешного входа
cy.url().should('not.include', '/signin', { timeout: 10000 });
});

13
cypress/support/e2e.ts Normal file

@ -0,0 +1,13 @@
// Import commands.js using ES2015 syntax:
import './commands';
// Alternatively you can use CommonJS syntax:
// require('./commands')
declare global {
namespace Cypress {
interface Chainable {
login(): Chainable<void>
}
}
}

@ -2,6 +2,6 @@ services:
squiz:
container_name: squiz
restart: unless-stopped
image: gitea.pena/squiz/frontpanel/main:$GITHUB_RUN_NUMBER
image: gitea.pena/squiz/frontpanel/main:1018
hostname: squiz
tty: true

@ -1,15 +1,7 @@
version: "3"
services:
squiz:
container_name: squiz
restart: unless-stopped
image: $CI_REGISTRY_IMAGE/staging:$CI_COMMIT_REF_SLUG.$CI_PIPELINE_ID
networks:
- marketplace_penahub_frontend
labels:
com.pena.domains: squiz.pena.digital
image: gitea.pena/squiz/frontpanel/staging:$GITHUB_RUN_NUMBER
hostname: squiz
tty: true
networks:
marketplace_penahub_frontend:
external: true

15
jest.config.js Normal file

@ -0,0 +1,15 @@
module.exports = {
transformIgnorePatterns: [
'/node_modules/(?!(@frontend/kitui|@frontend/squzanswerer)/)'
],
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/src/$1',
'^@utils/(.*)$': '<rootDir>/src/utils/$1',
'^@assets/(.*)$': '<rootDir>/src/assets/$1'
},
setupFilesAfterEnv: ['<rootDir>/src/setupTests.ts'],
testEnvironment: 'jsdom',
transform: {
'^.+\\.(ts|tsx)$': 'ts-jest'
}
};

1121
package-lock.json generated

File diff suppressed because it is too large Load Diff

@ -6,7 +6,7 @@
"@craco/craco": "^7.0.0",
"@emotion/react": "^11.10.5",
"@emotion/styled": "^11.10.5",
"@frontend/kitui": "^1.0.88",
"@frontend/kitui": "^1.0.108",
"@frontend/squzanswerer": "^1.0.57",
"@mui/icons-material": "^5.10.14",
"@mui/material": "^5.10.14",
@ -69,7 +69,9 @@
"test": "craco test",
"eject": "craco eject",
"code:format": "prettier --write --ignore-unknown",
"prepare": "husky install"
"prepare": "husky install",
"cypress:open": "cypress open",
"cypress:run": "cypress run"
},
"browserslist": {
"production": [

@ -35,6 +35,7 @@ const { DesignPage } = lazily(() => import("./pages/DesignPage/DesignPage"));
const { IntegrationsPage } = lazily(() => import("./pages/IntegrationsPage/IntegrationsPage"));
const { QuizAnswersPage } = lazily(() => import("./pages/QuizAnswersPage/QuizAnswersPage"));
const ChatImageNewWindow = lazy(() => import("@ui_kit/FloatingSupportChat/ChatImageNewWindow"));
const PersonalizationAI = lazy(() => import("./pages/PersonalizationAI/PersonalizationAI"));
let params = new URLSearchParams(document.location.search);
const isTest = Boolean(params.get("test"))
@ -60,6 +61,12 @@ const routeslink = [
sidebar: true,
footer: true,
},
{
path: "/personalization-ai",
page: PersonalizationAI,
header: true,
sidebar: true,
},
] as const;
const LazyLoading = ({ children, fallback }: SuspenseProps) => (

108
src/api/auditory.ts Normal file

@ -0,0 +1,108 @@
import { makeRequest } from "@frontend/kitui";
import { parseAxiosError } from "@utils/parse-error";
const API_URL = `${process.env.REACT_APP_DOMAIN}/squiz`;
// Types
export interface AuditoryItem {
id: number;
quiz_id: number;
sex: number; // 0 - женский, 1 - мужской, 2 - оба
age: string;
deleted: boolean;
created_at: number;
}
export interface AuditoryResponse {
ID: number;
quiz_id: number;
sex: number;
age: string;
deleted: boolean;
created_at: number;
}
// Request Types
export interface AuditoryGetRequest {
quizId: number;
}
export interface AuditoryDeleteRequest {
id: number;
}
export interface AuditoryAddRequest {
sex: number;
age: string;
}
// Parameters
export interface AuditoryGetParams {
quizId: number;
}
export interface AuditoryDeleteParams {
quizId: number;
auditoryId: number;
}
export interface AuditoryAddParams {
quizId: number;
body: AuditoryAddRequest;
}
// API calls
export const auditoryGet = async ({ quizId }: AuditoryGetParams): Promise<[AuditoryItem[] | null, string?]> => {
if (!quizId) {
return [null, "Quiz ID is required"];
}
try {
const response = await makeRequest<AuditoryGetRequest, AuditoryItem[]>({
url: `${API_URL}/quiz/${quizId}/auditory`,
method: "GET",
});
return [response];
} catch (nativeError) {
const [error] = parseAxiosError(nativeError);
return [null, `Не удалось получить аудиторию. ${error}`];
}
};
export const auditoryDelete = async ({ quizId, auditoryId }: AuditoryDeleteParams): Promise<[AuditoryResponse | null, string?]> => {
if (!quizId || !auditoryId) {
return [null, "Quiz ID and Auditory ID are required"];
}
try {
const response = await makeRequest<AuditoryDeleteRequest, AuditoryResponse>({
url: `${API_URL}/quiz/${quizId}/auditory/${auditoryId}`,
method: "DELETE",
});
return [response];
} catch (nativeError) {
const [error] = parseAxiosError(nativeError);
return [null, `Не удалось удалить аудиторию. ${error}`];
}
};
export const auditoryAdd = async ({ quizId, body }: AuditoryAddParams): Promise<[AuditoryResponse | null, string?]> => {
if (!quizId) {
return [null, "Quiz ID is required"];
}
try {
const response = await makeRequest<AuditoryAddRequest, AuditoryResponse>({
url: `${API_URL}/quiz/${quizId}/auditory`,
body,
method: "POST",
});
return [response];
} catch (nativeError) {
const [error] = parseAxiosError(nativeError);
return [null, `Не удалось добавить аудиторию. ${error}`];
}
};

@ -1,13 +1,11 @@
import { makeRequest } from "@api/makeRequest";
import { parseAxiosError } from "@utils/parse-error";
import type { GetTariffsResponse } from "@frontend/kitui";
const API_URL = `${process.env.REACT_APP_DOMAIN}/strator/tariff`;
export const getTariffs = async (
page: number,
page: number = 1,
): Promise<[GetTariffsResponse | null, string?]> => {
try {
const tariffs = await makeRequest<never, GetTariffsResponse>({
@ -17,7 +15,6 @@ export const getTariffs = async (
return [tariffs];
} catch (nativeError) {
const [error] = parseAxiosError(nativeError);
return [null, `Ошибка при получении списка тарифов. ${error}`];
}
};

9
src/api/tariffs.ts Normal file

@ -0,0 +1,9 @@
import { makeRequest } from '@utils/makeRequest';
import type { GetTariffsResponse } from '@/model/tariff';
export const getTariffs = async (): Promise<[GetTariffsResponse | null, string?]> => {
return makeRequest<GetTariffsResponse>({
url: `${process.env.REACT_APP_DOMAIN}/tariffs`,
method: 'GET'
});
};

@ -0,0 +1,5 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M19 15L19.6668 16.2577C20.1354 17.1416 20.8584 17.8646 21.7423 18.3332L23 19L21.7423 19.6668C20.8584 20.1354 20.1354 20.8584 19.6668 21.7423L19 23L18.3332 21.7423C17.8646 20.8584 17.1416 20.1354 16.2577 19.6668L15 19L16.2577 18.3332C17.1416 17.8646 17.8646 17.1416 18.3332 16.2577L19 15Z" stroke="#7E2AEA" stroke-width="1.5" stroke-linejoin="round"/>
<path d="M20 11V7C20 4.23858 17.7614 2 15 2H7C4.23858 2 2 4.23858 2 7V15C2 17.7614 4.23858 20 7 20H11" stroke="#7E2AEA" stroke-width="1.5" stroke-linecap="round"/>
<path d="M7.5 14.5V9.25C7.5 8.78587 7.68437 8.34075 8.01256 8.01256C8.34075 7.68437 8.78587 7.5 9.25 7.5C9.71413 7.5 10.1592 7.68437 10.4874 8.01256C10.8156 8.34075 11 8.78587 11 9.25V14.5M7.5 11.875H11M14.5 7.5V14.5" stroke="#7E2AEA" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 930 B

@ -0,0 +1,12 @@
import * as React from "react";
import { SvgIcon, SvgIconProps } from "@mui/material";
const AiPersonalizationIcon = (props: SvgIconProps) => (
<SvgIcon {...props} viewBox="0 0 24 24" fill="none">
<path d="M19 15L19.6668 16.2577C20.1354 17.1416 20.8584 17.8646 21.7423 18.3332L23 19L21.7423 19.6668C20.8584 20.1354 20.1354 20.8584 19.6668 21.7423L19 23L18.3332 21.7423C17.8646 20.8584 17.1416 20.1354 16.2577 19.6668L15 19L16.2577 18.3332C17.1416 17.8646 17.8646 17.1416 18.3332 16.2577L19 15Z" stroke="#7E2AEA" strokeWidth="1.5" strokeLinejoin="round"/>
<path d="M20 11V7C20 4.23858 17.7614 2 15 2H7C4.23858 2 2 4.23858 2 7V15C2 17.7614 4.23858 20 7 20H11" stroke="#7E2AEA" strokeWidth="1.5" strokeLinecap="round"/>
<path d="M7.5 14.5V9.25C7.5 8.78587 7.68437 8.34075 8.01256 8.01256C8.34075 7.68437 8.78587 7.5 9.25 7.5C9.71413 7.5 10.1592 7.68437 10.4874 8.01256C10.8156 8.34075 11 8.78587 11 9.25V14.5M7.5 11.875H11M14.5 7.5V14.5" stroke="#7E2AEA" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
</SvgIcon>
);
export default AiPersonalizationIcon;

@ -83,11 +83,11 @@ const GeneralItem = ({
xAxis={[
{
data: statiscticsResult ? days : Object.keys(general),
valueFormatter: (value) =>
moment.unix(Number(value)).format("DD/MM/YYYY HH") +
statiscticsResult
? ""
: "ч",
valueFormatter: (value) => {
const timestamp = Number(value);
if (isNaN(timestamp)) return 'Invalid Date';
return moment.unix(timestamp).format(statiscticsResult ? "DD/MM/YYYY" : "DD/MM/YYYY HH") + (statiscticsResult ? "" : "ч");
},
},
]}
series={[

@ -3,8 +3,10 @@ import { decrementCurrentStep } from "@root/quizes/actions";
import ArrowLeft from "@/assets/icons/questionsPage/arrowLeft";
import QuizInstallationCard from "./QuizInstallationCard/QuizInstallationCard";
import QuizLinkCard from "./QuizLinkCard";
import { useNavigate } from "react-router-dom";
export default function InstallQuiz() {
const navigate = useNavigate();
return (
<>
<Box
@ -33,6 +35,11 @@ export default function InstallQuiz() {
>
<ArrowLeft />
</Button>
<Button
variant="contained"
sx={{ padding: "10px 20px", borderRadius: "8px" }}
onClick={() => navigate("/list")}
>На главную</Button>
</Box>
</Box>
</>

@ -0,0 +1,181 @@
import React, { useState, useRef } from 'react';
import {
Box,
InputBase,
IconButton,
Menu,
MenuItem,
Paper,
Popper,
Grow,
ClickAwayListener,
MenuList,
useTheme,
FormHelperText
} from '@mui/material';
import ArrowDownIcon from "@/assets/icons/ArrowDownIcon";
interface AgeInputWithSelectProps {
value: string;
onChange: (value: string) => void;
onErrorChange?: (isError: boolean) => void;
}
const AgeInputWithSelect = ({ value, onChange, onErrorChange }: AgeInputWithSelectProps) => {
const theme = useTheme();
const [open, setOpen] = useState(false);
const anchorRef = useRef<HTMLDivElement>(null);
const [errorType, setErrorType] = useState<'format' | 'range' | false>(false);
// Валидация: только число или диапазон число-число, и диапазон 0-150
const validate = (val: string) => {
if (!val) return false;
// Число (только положительное)
if (/^-?\d+$/.test(val)) {
const num = Number(val);
if (num < 0 || num > 150) return 'range';
return false;
}
// Диапазон (только положительные числа)
const rangeMatch = val.match(/^(-?\d+)-(-?\d+)$/);
if (rangeMatch) {
const left = Number(rangeMatch[1]);
const right = Number(rangeMatch[2]);
if (left < 0 || left > 150 || right < 0 || right > 150 || left > right) return 'range';
return false;
}
return 'format';
};
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const filtered = e.target.value.replace(/[^\d-]/g, '');
onChange(filtered);
const err = validate(filtered);
setErrorType(err);
if (onErrorChange) onErrorChange(!!err);
};
const handleInputBlur = (e: React.FocusEvent<HTMLInputElement>) => {
const trimmed = e.target.value.replace(/\s+/g, '');
onChange(trimmed);
const err = validate(trimmed);
setErrorType(err);
if (onErrorChange) onErrorChange(!!err);
};
const handleSelectItemClick = (selectedValue: string) => {
onChange(selectedValue);
setErrorType(false);
setOpen(false);
};
const handleToggle = () => {
setOpen((prevOpen) => !prevOpen);
};
const handleClose = (event: Event) => {
if (anchorRef.current && anchorRef.current.contains(event.target as HTMLElement)) {
return;
}
setOpen(false);
};
return (
<Box
ref={anchorRef}
sx={{
position: 'relative',
mt: "17px",
height: "48px",
maxWidth: "420px",
width: "100%",
borderRadius: "8px",
border: "1px solid #9A9AAF",
'&:hover': {
borderColor: '#B0B0B0',
},
'&:focus-within': {
borderColor: '#7E2AEA',
}
}}
>
<InputBase
value={value}
onChange={handleInputChange}
onBlur={handleInputBlur}
fullWidth
placeholder="Введите возраст"
inputProps={{ inputMode: 'numeric', pattern: '[0-9-]*' }}
sx={{
height: "100%",
padding: "10px 20px",
'& input': {
height: "100%",
width: "100%",
}
}}
/>
{errorType === 'format' && (
<FormHelperText error sx={{ position: 'absolute', left: 0, top: '100%', mt: '2px', ml: '10px' }}>
можно только число или диапазон
</FormHelperText>
)}
{errorType === 'range' && (
<FormHelperText error sx={{ position: 'absolute', left: 0, top: '100%', mt: '2px', ml: '10px' }}>
таких возрастов нет
</FormHelperText>
)}
<IconButton
onClick={handleToggle}
sx={{
position: 'absolute',
right: 0,
top: '50%',
transform: `translateY(-50%) rotate(${open ? 180 : 0}deg)`,
cursor: 'pointer',
color: theme.palette.brightPurple.main,
display: 'flex',
alignItems: 'center',
transition: 'transform 0.2s',
padding: '8px'
}}
>
<ArrowDownIcon style={{ width: "18px", height: "18px" }} />
</IconButton>
<Popper
open={open}
anchorEl={anchorRef.current}
role={undefined}
placement="bottom-end"
transition
disablePortal
sx={{ zIndex: 1300 }}
>
{({ TransitionProps, placement }) => (
<Grow
{...TransitionProps}
style={{
transformOrigin: placement === 'bottom' ? 'center top' : 'center bottom',
}}
>
<Paper elevation={8}>
<ClickAwayListener onClickAway={handleClose}>
<MenuList autoFocusItem={open} id="menu-list-grow">
<MenuItem onClick={() => handleSelectItemClick('')}>Выберите возраст</MenuItem>
<MenuItem onClick={() => handleSelectItemClick('18-24')}>18-24</MenuItem>
<MenuItem onClick={() => handleSelectItemClick('25-34')}>25-34</MenuItem>
<MenuItem onClick={() => handleSelectItemClick('35-44')}>35-44</MenuItem>
<MenuItem onClick={() => handleSelectItemClick('45-54')}>45-54</MenuItem>
<MenuItem onClick={() => handleSelectItemClick('55+')}>55+</MenuItem>
</MenuList>
</ClickAwayListener>
</Paper>
</Grow>
)}
</Popper>
</Box>
);
};
export default AgeInputWithSelect;

@ -0,0 +1,98 @@
import { AuditoryItem } from "@/api/auditory";
import Trash from "@/assets/icons/trash";
import { useCurrentQuiz } from "@/stores/quizes/hooks";
import { InfoPopover } from "@/ui_kit/InfoPopover";
import TooltipClickInfo from "@/ui_kit/Toolbars/TooltipClickInfo";
import { useDomainDefine } from "@/utils/hooks/useDomainDefine";
import { IconButton, ListItem, Typography, useTheme } from "@mui/material";
import { CopyButton } from "./CopyButton";
interface AuditoryLinkProps {
item: AuditoryItem;
index: number;
onDelete: (id: number) => void;
utmParams: string
}
export const AuditoryLink = ({ utmParams, item, index, onDelete }: AuditoryLinkProps) => {
const theme = useTheme();
const quiz = useCurrentQuiz();
const { isTestServer } = useDomainDefine();
const handleCopy = (text: string) => {
navigator.clipboard.writeText(text);
};
const handleDelete = () => {
onDelete(item.id);
};
const linkText = `${isTestServer ? "https://s.hbpn.link/" : "https://hbpn.link/"}${quiz?.qid}?_paud=${item.id}${utmParams}`;
return (
<ListItem
key={index}
disablePadding
sx={{
bgcolor: "#F2F3F7",
borderRadius: "10px",
p: "13px 14px 13px 20px",
mb: "8px",
maxWidth: "756px",
display: "flex",
alignItems: "center",
transition: 'background 0.2s, border 0.2s',
'& .MuiListItemSecondaryAction-root': {
display: 'flex',
alignItems: 'center',
gap: '12px',
width: "70px",
justifyContent: "space-between",
},
}}
secondaryAction={
<>
<IconButton
className="delete_aud"
edge="end"
aria-label="delete"
sx={{ color: theme.palette.brightPurple.main, p: 0, width: 18, height: 18 }}
onClick={handleDelete}
>
<Trash sx={{
"& path": {
stroke: theme.palette.brightPurple.main,
}
}} />
</IconButton>
<IconButton edge="end" aria-label="info" sx={{ color: theme.palette.brightPurple.main, p: 0, width: 18, height: 18 }}>
<TooltipClickInfo title={`Пол: ${item.sex === 0 ? "женский" : item.sex === 1 ? "мужской" : "оба"} \n Возраст: ${item.age}`} />
</IconButton>
<CopyButton
created_at={item.created_at}
onCopy={handleCopy}
text={linkText}
/>
</>
}
>
<Typography
className="link"
sx={{
color: 'black',
fontWeight: 400,
fontSize: "16px",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
overflow: "hidden",
width: "calc(100% - 80px)",
userSelect: "none",
WebkitUserSelect: "none",
MozUserSelect: "none",
msUserSelect: "none"
}}>
{linkText}
</Typography>
</ListItem>
);
};

@ -0,0 +1,49 @@
import { auditoryGet, AuditoryResponse, AuditoryItem } from "@/api/auditory";
import ArrowDownIcon from "@/assets/icons/ArrowDownIcon";
import { useCurrentQuiz } from "@/stores/quizes/hooks";
import { useDomainDefine } from "@/utils/hooks/useDomainDefine";
import { Box, Collapse, IconButton, List, Typography, useTheme } from "@mui/material";
import { useEffect, useState } from "react";
import { AuditoryLink } from "./AuditoryLink";
export const AuditoryList = ({utmParams, auditory, onDelete}:{utmParams:string,auditory:AuditoryItem[], onDelete: (id: number) => void}) => {
const theme = useTheme();
const { isTestServer } = useDomainDefine();
const [linksOpen, setLinksOpen] = useState(true);
console.log("auditory-___---_auditory__---__-__auditory_------__---__-__---_------__---__-__---_------__---__-____--__")
console.log(auditory)
return (
<>
<Box sx={{
maxWidth: "796px",
bgcolor: "#fff",
borderRadius: "12px",
p: "20px",
boxShadow: "0px 4px 32px 0px #7E2AEA14",
mt: "24px"
}}>
<Box sx={{ display: 'flex', justifyContent: 'space-between' }}>
<Typography sx={{ fontSize: "18px", fontWeight: 500, color: theme.palette.grey3.main }}>
Ваши сохраненные ссылки
</Typography>
<IconButton
sx={{ cursor: 'pointer', color: theme.palette.brightPurple.main, display: 'flex', alignItems: 'center', transition: 'transform 0.2s', transform: linksOpen ? 'rotate(0deg)' : 'rotate(180deg)' }}
onClick={() => setLinksOpen((prev) => !prev)}
size="large"
>
<ArrowDownIcon style={{ width: "18px", height: "18px" }} />
</IconButton>
</Box>
<Collapse in={linksOpen} timeout="auto" unmountOnExit sx={{ mt: "3px" }}>
<List sx={{ gap: '8px', p: 0, m: 0 }}>
{auditory.map((item, idx) => (
<AuditoryLink utmParams={utmParams} key={idx} item={item} index={idx} onDelete={onDelete} />
))}
</List>
</Collapse>
</Box>
</>
);
};

@ -0,0 +1,161 @@
import { IconButton, Skeleton, useTheme, Tooltip, ClickAwayListener } from "@mui/material";
import { useEffect, useState } from "react";
import CopyIcon from "@/assets/icons/CopyIcon";
import { useSnackbar } from "notistack";
interface CopyButtonProps {
created_at: number;
onCopy: (text: string) => void;
text: string;
}
export const CopyButton = ({ created_at, onCopy, text }: CopyButtonProps) => {
const theme = useTheme();
const { enqueueSnackbar } = useSnackbar();
const [open, setOpen] = useState(false);
const [timeLeft, setTimeLeft] = useState<string>("");
const getCreatedTime = (timestamp: number) => {
// Если timestamp в секундах (10 цифр)
if (timestamp.toString().length === 10) {
return new Date(timestamp * 1000).getTime();
}
// Если timestamp в миллисекундах (13 цифр)
return new Date(timestamp).getTime();
};
const formatTimeLeft = (milliseconds: number) => {
const minutes = Math.floor(milliseconds / (1000 * 60));
const seconds = Math.floor((milliseconds % (1000 * 60)) / 1000);
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
};
const [isLoading, setIsLoading] = useState(() => {
if (!created_at) return false;
const now = new Date().getTime();
const created = getCreatedTime(created_at);
const diffInMinutes = (now - created) / (1000 * 60);
return diffInMinutes < 3;
});
useEffect(() => {
if (!created_at) return;
const now = new Date().getTime();
const created = getCreatedTime(created_at);
const diffInMinutes = (now - created) / (1000 * 60);
if (now - created < 1000) {
setIsLoading(true);
}
if (diffInMinutes < 3) {
const timeLeft = Math.ceil((3 - diffInMinutes) * 60 * 1000);
setTimeLeft(formatTimeLeft(timeLeft));
const timer = setInterval(() => {
const currentTime = new Date().getTime();
const elapsed = currentTime - created;
const remaining = 3 * 60 * 1000 - elapsed;
if (remaining <= 0) {
setIsLoading(false);
clearInterval(timer);
return;
}
setTimeLeft(formatTimeLeft(remaining));
}, 1000);
return () => clearInterval(timer);
}
}, [created_at]);
const handleClick = () => {
if (isLoading) return;
onCopy(text);
enqueueSnackbar("Ссылка успешно скопирована", { variant: "success" });
};
const handleTooltipClose = () => {
setOpen(false);
};
const handleTooltipOpen = () => {
setOpen(true);
};
if (isLoading) {
return (
<ClickAwayListener onClickAway={handleTooltipClose}>
<div>
<Tooltip
PopperProps={{
disablePortal: true,
sx: {
"& .MuiTooltip-tooltip": {
minWidth: "175px",
maxWidth: "175px",
whiteSpace: "normal",
textAlign: "center"
}
}
}}
placement="top"
onClose={handleTooltipClose}
open={open}
title={`Идёт процесс генерации вопросов, он будет закончен через ${timeLeft}`}
onMouseEnter={handleTooltipOpen}
onMouseLeave={handleTooltipClose}
sx={{
fontSize: "12px",
p: "10px"
}}
>
<Skeleton
variant="circular"
width={18}
height={18}
sx={{
bgcolor: theme.palette.grey[400],
marginRight: "-2px"
}}
/>
</Tooltip>
</div>
</ClickAwayListener>
);
}
return (
<IconButton
edge="end"
aria-label="copy"
sx={{
color: theme.palette.brightPurple.main,
p: 0,
width: 18,
height: 18,
marginRight: "-2px",
position: "relative",
"&:hover": {
"&::after": {
content: '""',
position: "absolute",
top: "-15px",
left: "-15px",
right: "-15px",
bottom: "-15px",
borderRadius: "50%",
backgroundColor: theme.palette.grey[500],
opacity: 0.1,
zIndex: -1
}
}
}}
onClick={handleClick}
>
<CopyIcon color={theme.palette.brightPurple.main} />
</IconButton>
);
};

@ -0,0 +1,212 @@
import { Box, FormControl, FormLabel, Checkbox, FormControlLabel, useTheme, Button, useMediaQuery } from "@mui/material";
import CheckboxIcon from "@icons/Checkbox";
import AgeInputWithSelect from "./AgeInputWithSelect";
import { useState, useEffect } from "react";
interface GenderAndAgeSelectorProps {
gender: string;
age: string;
ageError: boolean;
onGenderChange: (gender: string) => void;
onAgeChange: (age: string) => void;
onAgeErrorChange: (error: boolean) => void;
onStartCreate: () => void;
}
export default function GenderAndAgeSelector({
gender,
age,
ageError,
onGenderChange,
onAgeChange,
onAgeErrorChange,
onStartCreate
}: GenderAndAgeSelectorProps) {
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down(845));
const [maleChecked, setMaleChecked] = useState(false);
const [femaleChecked, setFemaleChecked] = useState(false);
// Синхронизируем состояние чекбоксов с пропсом gender
useEffect(() => {
if (gender === '1') {
setMaleChecked(true);
setFemaleChecked(false);
} else if (gender === '0') {
setMaleChecked(false);
setFemaleChecked(true);
} else if (gender === '2') {
setMaleChecked(true);
setFemaleChecked(true);
} else {
setMaleChecked(false);
setFemaleChecked(false);
}
}, [gender]);
const handleGenderChange = (type: 'male' | 'female', checked: boolean) => {
if (type === 'male') {
setMaleChecked(checked);
} else {
setFemaleChecked(checked);
}
// Обновляем значение gender в родительском компоненте
if (type === 'male' && checked && !femaleChecked) {
onGenderChange('1'); // Только мужской
} else if (type === 'female' && checked && !maleChecked) {
onGenderChange('0'); // Только женский
} else if (type === 'male' && checked && femaleChecked) {
onGenderChange('2'); // Оба пола
} else if (type === 'female' && checked && maleChecked) {
onGenderChange('2'); // Оба пола
} else if (type === 'male' && !checked && femaleChecked) {
onGenderChange('0'); // Только женский
} else if (type === 'female' && !checked && maleChecked) {
onGenderChange('1'); // Только мужской
} else {
onGenderChange(''); // Ничего не выбрано
}
};
return (
<Box sx={{ display: 'flex', gap: 4, alignItems: "end", flexWrap: "wrap" }}>
<Box sx={{ display: "inline-flex", flexWrap: isMobile ? "wrap" : "initial" }}>
<FormControl component="fieldset" variant="standard">
<Box sx={{ display: 'flex', alignItems: "end", gap: '4px' }}>
<FormLabel
sx={{
'&.Mui-focused': {
color: '#4D4D4D',
},
color: '#4D4D4D',
fontSize: '18px',
fontWeight: 500,
lineHeight: 1,
letterSpacing: 0,
mr: '3px',
}}
component="legend">Пол</FormLabel>
</Box>
<Box
sx={{
width: "155px",
justifyContent: "space-between",
mt: "20px",
ml: "-9px",
display: 'flex',
gap: '10px'
}}
>
<FormControlLabel
sx={{
padding: 0,
'& .MuiTouchRipple-root': {
width: '100%',
height: '100%',
overflow: 'visible',
},
'& .MuiSvgIcon-root': {
position: 'absolute',
left: '50%',
top: '50%',
transform: 'translate(-50%, -50%)',
},
'& .MuiFormControlLabel-label': {
fontSize: '18px',
fontWeight: 400,
fontFamily: 'Rubik',
lineHeight: 1,
letterSpacing: 0,
color: '#4D4D4D',
ml: "6px"
},
m: 0,
}}
control={
<Checkbox
icon={<CheckboxIcon />}
checkedIcon={<CheckboxIcon checked />}
checked={maleChecked}
onChange={(e) => handleGenderChange('male', e.target.checked)}
/>
}
label="М"
/>
<FormControlLabel
sx={{
padding: 0,
'& .MuiTouchRipple-root': {
width: '100%',
height: '100%',
overflow: 'visible',
},
'& .MuiSvgIcon-root': {
position: 'absolute',
left: '50%',
top: '50%',
transform: 'translate(-50%, -50%)',
},
'& .MuiFormControlLabel-label': {
fontSize: '18px',
fontWeight: 400,
fontFamily: 'Rubik',
lineHeight: 1,
letterSpacing: 0,
color: '#4D4D4D',
},
m: 0,
}}
control={
<Checkbox
icon={<CheckboxIcon />}
checkedIcon={<CheckboxIcon checked />}
checked={femaleChecked}
onChange={(e) => handleGenderChange('female', e.target.checked)}
/>
}
label="Ж"
/>
</Box>
</FormControl>
<FormControl sx={{ maxWidth: "420px", width: "100%", marginLeft: isMobile ? "0" : "120px", minWidth: "265px" }} variant="filled">
<Box sx={{
display: 'flex',
alignItems: 'end',
gap: '4px'
}}>
<FormLabel sx={{
color: '#4D4D4D',
fontSize: "18px",
fontWeight: 500,
lineHeight: "100%",
'&.Mui-focused': {
color: '#4D4D4D',
},
}}>Возраст</FormLabel>
</Box>
<AgeInputWithSelect value={age} onChange={onAgeChange} onErrorChange={onAgeErrorChange} />
</FormControl>
</Box>
<Button
onClick={onStartCreate}
variant="contained"
disabled={!gender || !age || ageError}
sx={{
bgcolor: theme.palette.brightPurple.main,
borderRadius: "8px",
width: "130px",
height: "48px",
boxShadow: "none",
textTransform: "none",
fontSize: "18px",
'&:hover': { bgcolor: theme.palette.brightPurple.main },
}}
>
Ок
</Button>
</Box>
);
}

@ -0,0 +1,54 @@
import React from 'react';
import { Box, Typography, useTheme } from '@mui/material';
import { useMediaQuery } from '@mui/material';
import { InfoPopover } from './InfoPopover';
import { GenderButton } from './GenderButton';
interface GenderSelectorProps {
value: string[];
onChange: (value: string[]) => void;
}
export const GenderSelector: React.FC<GenderSelectorProps> = ({ value, onChange }) => {
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('sm'));
const handleGenderClick = (gender: string) => {
if (value.includes(gender)) {
onChange(value.filter(g => g !== gender));
} else {
onChange([...value, gender]);
}
};
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<Typography
sx={{
fontSize: isMobile ? '14px' : '16px',
fontWeight: 500,
color: theme.palette.text.primary,
}}
>
Пол
</Typography>
<InfoPopover />
</Box>
<Box sx={{ display: 'flex', gap: '8px' }}>
<GenderButton
selected={value.includes('male')}
onClick={() => handleGenderClick('male')}
icon="male"
label="Мужской"
/>
<GenderButton
selected={value.includes('female')}
onClick={() => handleGenderClick('female')}
icon="female"
label="Женский"
/>
</Box>
</Box>
);
};

@ -0,0 +1,54 @@
import { Box, Button, Modal, Typography } from "@mui/material"
interface Props {
open: boolean;
onClose: () => void;
onCreate: () => void;
}
export const PayModal = ({
open,
onClose,
onCreate
}: Props) => {
return (
<Modal
open={open}
onClose={onClose}
aria-labelledby="modal-modal-title"
aria-describedby="modal-modal-description"
>
<Box
sx={{
position: "absolute" as "absolute",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
maxWidth: "550px",
width: "100%",
bgcolor: "background.paper",
borderRadius: "12px",
boxShadow: 24,
p: "30px",
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: 2
}}
>
<Typography sx={{ width: "100%", textAlign: "center", mb: "25px" }}>
Данная услуга предоставляется за 500 рублей/опрос. Готовы оплатить?
</Typography>
<Box sx={{ display: 'flex', gap: 2 }}>
<Button sx={{width: "100px"}} variant="outlined" onClick={onClose}>
Нет
</Button>
<Button sx={{width: "100px"}} variant="contained" onClick={onCreate}>
Да
</Button>
</Box>
</Box>
</Modal>
);
}

@ -0,0 +1,392 @@
import { Box, Container, Typography, TextField, Button, List, ListItem, IconButton, Modal } from "@mui/material";
import { InfoPopover } from '@ui_kit/InfoPopover';
import GenderAndAgeSelector from "./GenderAndAgeSelector";
import { useEffect, useState } from "react";
import CustomTextField from "@ui_kit/CustomTextField";
import { useTheme } from "@mui/material";
import { AuditoryItem, auditoryAdd, auditoryDelete, auditoryGet } from "@/api/auditory";
import { useCurrentQuiz } from "@/stores/quizes/hooks";
import { AuditoryList } from "./AuditoryList";
import { useSnackbar } from "notistack";
import { PayModal } from "./PayModal";
import { useUserStore } from "@/stores/user";
import { cartApi } from "@/api/cart";
import { outCart } from "../Tariffs/Tariffs";
import { inCart } from "../Tariffs/Tariffs";
import { isTestServer } from "@/utils/hooks/useDomainDefine";
import { useToken } from "@frontend/kitui";
import { useSWRConfig } from "swr";
import { makeRequest } from "@api/makeRequest";
import { setUserAccount, setCustomerAccount } from "@/stores/user";
import { quizApi } from "@api/quiz";
import { setQuizes } from "@root/quizes/actions";
import TooltipClickInfo from "@/ui_kit/Toolbars/TooltipClickInfo";
const tariff = isTestServer ? "6844b8858258f5cc35791ef7" : "6851db40acfb4d3e5fcd9b19";
export default function PersonalizationAI() {
const theme = useTheme();
const [auditory, setAuditory] = useState<AuditoryItem[]>([]);
const [deleteModal, setDeleteModal] = useState<number>(0);
const [link, setLink] = useState<string>('');
const [utmParams, setUtmParams] = useState<string>('');
const quiz = useCurrentQuiz();
const { enqueueSnackbar } = useSnackbar();
const privilegesOfUser = useUserStore((state) => state.userAccount?.privileges);
const user = useUserStore((state) => state.customerAccount);
const token = useToken();
const userId = useUserStore((state) => state.userId);
const [gender, setGender] = useState<string>('');
const [age, setAge] = useState<string>('');
const [ageError, setAgeError] = useState(false);
const [isModalOpen, setIsModalOpen] = useState(false);
// Обновляем данные пользователя через SWR
const { mutate } = useSWRConfig();
const resetForm = () => {
setGender('');
setAge('');
setAgeError(false);
};
const createNewLink = async () => {
if (!quiz?.backendId) {
enqueueSnackbar('Ошибка: не выбран квиз', { variant: 'error' });
return;
}
try {
const [result, error] = await auditoryAdd({
quizId: quiz.backendId,
body: {
sex: parseInt(gender),
age
}
});
if (error) {
enqueueSnackbar('Не удалось добавить ссылку', { variant: 'error' });
return [, error];
}
if (result) {
handleAdd({
id: result.ID,
quiz_id: quiz.backendId,
sex: parseInt(gender),
age,
deleted: false,
created_at: Date.now()
});
enqueueSnackbar('Ссылка успешно добавлена', { variant: 'success' });
resetForm();
setIsModalOpen(false);
// Обновляем данные пользователя после успешного создания ссылки
try {
const [userAccountResult, customerAccountResult] = await Promise.all([
makeRequest({
url: `${process.env.REACT_APP_DOMAIN}/squiz/account/get`,
method: "GET",
useToken: true,
withCredentials: false,
}).catch(error => {
console.log(error)
enqueueSnackbar("Ошибка при обновлении данных пользователя", { variant: "error" });
return null;
}),
makeRequest({
url: `${process.env.REACT_APP_DOMAIN}/customer/v1.0.1/account`,
method: "GET",
useToken: true,
withCredentials: false,
}).catch(error => {
console.log(error)
enqueueSnackbar("Ошибка при обновлении данных клиента", { variant: "error" });
return null;
})
]);
if (userAccountResult) {
setUserAccount(userAccountResult);
}
if (customerAccountResult) {
setCustomerAccount(customerAccountResult);
}
} catch (error) {
console.log(error)
enqueueSnackbar("Ошибка при обновлении данных", { variant: "error" });
}
}
} catch (error) {
enqueueSnackbar('Произошла ошибка при добавлении', { variant: 'error' });
}
};
useEffect(() => {
(async () => {
if (quiz?.backendId) {
const [result, error] = await auditoryGet({ quizId: quiz.backendId });
console.log("result-___---_------__---__-__---_------__---__-__---_------__---__-__---_------__---__-____--__")
console.log(result)
if (result) {
setAuditory(result);
}
}
})();
}, [quiz]);
const handleDelete = async () => {
// 1. Закрываем модалку
setDeleteModal(0);
// 2. Находим индекс объекта в стейте
const indexToDelete = auditory.findIndex(item => item.id === deleteModal);
if (indexToDelete === -1) return;
// 3. Сохраняем удаляемый объект
const deletedItem = auditory[indexToDelete];
// 4. Меняем стейт, вырезая объект
setAuditory(prev => prev.filter(item => item.id !== deleteModal));
try {
// 5. Вызываем функцию удаления
const [result, error] = await auditoryDelete({
quizId: quiz?.backendId,
auditoryId: deleteModal
});
if (error) {
// 6. Если удалить не удалось - показываем снекбар и возвращаем ссылку
enqueueSnackbar('Не удалось удалить ссылку', { variant: 'error' });
setAuditory(prev => {
const newArray = [...prev];
newArray.splice(indexToDelete, 0, deletedItem);
return newArray;
});
}
} catch (error) {
// Обработка ошибки сети или других ошибок
enqueueSnackbar('Произошла ошибка при удалении', { variant: 'error' });
setAuditory(prev => {
const newArray = [...prev];
newArray.splice(indexToDelete, 0, deletedItem);
return newArray;
});
}
}
const handleAdd = (item: AuditoryItem) => {
setAuditory(old => ([...old, item]));
// Очищаем форму после успешного добавления
setGender('');
setAge('');
}
const handleLinkChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newLink = e.target.value;
setLink(newLink);
// Регулярное выражение для поиска параметров URL
const paramRegex = /[?&]([^=&]+)=([^&]*)/g;
const params: Record<string, string> = {};
let match;
// Ищем все параметры в строке
while ((match = paramRegex.exec(newLink)) !== null) {
const key = decodeURIComponent(match[1]);
const value = decodeURIComponent(match[2]);
params[key] = value;
}
// Преобразуем объект параметров в строку URL
const paramString = Object.entries(params)
.map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`)
.join('&');
setUtmParams(paramString ? `&${paramString}` : "");
};
console.log("______----giga_chat-----__--_---_--_----__--__-__--_--__--__--_---_______-quiz")
console.log(quiz?.giga_chat)
const startCreate = async () => {
if (quiz?.giga_chat) {
createNewLink();
} else {
setIsModalOpen(true);
}
};
const tryBuy = async ({ id, price }: { id: string; price: number }) => {
//Если в корзине что-то было - выкладываем содержимое и запоминаем чо там лежало
if (user?.cart?.length > 0) {
outCart(user.cart);
}
//Добавляем желаемый тариф в корзину
const [_, addError] = await cartApi.add(tariff);
if (addError) {
//Развращаем товары в корзину
inCart();
return;
}
//Если нам хватает денежек - покупаем тариф
const [data, payError] = await cartApi.pay();
if (payError || !data) {
//если денег не хватило
if (payError?.includes("insufficient funds") || payError?.includes("Payment Required")) {
var link = document.createElement("a");
link.href = `https://${isTestServer ? "s" : ""}hub.pena.digital/quizpayment?action=squizpay&dif=50000&data=${token}&userid=${userId}&from=AI&wayback=ai_${quiz?.backendId}`;
document.body.appendChild(link);
link.click();
return;
}
//другая ошибка
enqueueSnackbar("Произошла ошибка. Попробуйте позже");
return;
}
//Развращаем товары в корзину
inCart();
//Показываем сообщение об успешной покупке
enqueueSnackbar("Тариф успешно приобретен", { variant: "success" });
// Создаем новую ссылку после обновления данных
await createNewLink();
// Обновляем данные квиза после успешной оплаты
console.log("Обновляем данные квиза после оплаты");
const [quizes, quizesError] = await quizApi.getList();
console.log("Получены данные квизов:", quizes);
if (!quizesError) {
setQuizes(quizes);
console.log("Данные квизов обновлены в сторе");
} else {
console.error("Ошибка при получении данных квизов:", quizesError);
}
};
return (
<>
<Container id="PersonalizationAI" maxWidth={false} sx={{ minHeight: "100%", p: "20px", height: " calc(100vh - 80px)", overflow: "auto", pt: "55px" }}>
<Typography variant="h5" color={theme.palette.grey3.main} fontWeight={700} sx={{ fontSize: 24, letterSpacing: "-0.2px" }}>
Персонализация вопросов с помощью AI
</Typography>
<Typography sx={{
color: theme.palette.grey3.main, fontSize: "18px", maxWidth: 796, m: 0,
mt: "19px",
letterSpacing: "0.009px",
wordSpacing: "0.1px",
lineHeight: "21.4px"
}}>
Данный раздел позволяет вам создавать персонализированный опрос под каждую целевую аудиторию отдельно, наш AI перефразирует ваши вопросы согласно настройкам.
Для этого нужно выбрать пол и возраст вашей аудитории и получите персональную ссылку с нужными настройками в списке ниже.
Так же вы можете обогатить свою ссылку UTM метками в поле "вставьте свою ссылку"и этим метки применятся ко всем вашим ссылкам.
ВАЖНО: если ваши вопросы уже подходят целевой аудитории, то персонализированы они скорее всего не будут. {/* Первый белый блок */}
</Typography>
<Box sx={{
bgcolor: "#fff",
borderRadius: "12px",
mt: "40px",
p: "20px 20px 30px",
boxShadow: "none",
maxWidth: "796px"
}}>
<GenderAndAgeSelector
gender={gender}
age={age}
ageError={ageError}
onGenderChange={setGender}
onAgeChange={setAge}
onAgeErrorChange={setAgeError}
onStartCreate={startCreate}
/>
{/* Ссылка */}
<Box sx={{ mt: "34px" }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
<Typography sx={{ color: theme.palette.grey3.main, fontSize: "18px", fontWeight: 500 }}>Ссылка</Typography>
<TooltipClickInfo title={`Данное поле создано для обогащения utm метками вашей ссылки. Нужно скопировать ссылку вашего квиза, задать настройки ца, вставить ссылку в поле, прописать метки(советуем использовать динамические), и нажать "ок" выше поля. Метки будут применены ко всем ссылкам с персонализацией в рамках данного квиза.`}/>
{/* <InfoPopover >
<Typography sx={{maxWidth: "300px"}} >
Данное поле создано для обогащения utm метками вашей ссылки. Нужно скопировать ссылку вашего квиза, задать настройки ца, вставить ссылку в поле, прописать метки(советуем использовать динамические), и нажать "ок" выше поля. Метки будут применены ко всем ссылкам с персонализацией в рамках данного квиза.
</Typography>
</InfoPopover> */}
</Box>
<Typography
sx={{
fontSize: "14px",
lineHeight: "100%",
letterSpacing: "0 %",
color: theme.palette.grey2.main,
mt: "16px"
}}
>
Вставьте ссылку со всеми utm-метками
</Typography>
<CustomTextField
placeholder="linkexample.com"
maxLength={500}
value={link}
onChange={handleLinkChange}
sxForm={{
maxWidth: "615px",
width: "100%",
}}
/>
</Box>
</Box>
<AuditoryList utmParams={utmParams} onDelete={setDeleteModal} auditory={auditory} />
</Container>
<Modal
open={Boolean(deleteModal)}
onClose={() => setDeleteModal(0)}
aria-labelledby="modal-modal-title"
aria-describedby="modal-modal-description"
>
<Box
sx={{
position: "absolute" as "absolute",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
maxWidth: "620px",
width: "100%",
bgcolor: "background.paper",
borderRadius: "12px",
boxShadow: 24,
p: "20px",
display: "flex",
flexDirection: "column",
alignItems: "center"
}}
>
<Typography sx={{ width: "100%", textAlign: "center", mb: "25px" }}>Уверены, что хотите удалить ссылку?</Typography>
<Button sx={{ mb: "20px" }} id="delete_OK" onClick={handleDelete}>Удалить</Button>
<Button variant="contained" onClick={() => setDeleteModal(0)} >Отмена</Button>
</Box>
</Modal>
<PayModal
open={isModalOpen}
onClose={() => {
setIsModalOpen(false);
}}
onCreate={tryBuy}
/>
</>
);
}

@ -2,7 +2,11 @@ import { Box } from "@mui/material";
import { reorderQuestionVariants } from "@root/questions/actions";
import { type ReactNode } from "react";
import type { DropResult } from "react-beautiful-dnd";
import { DragDropContext, Droppable } from "react-beautiful-dnd";
import { DragDropContext as DragDropContextOriginal } from "react-beautiful-dnd";
import { StrictModeDroppable } from "./StrictModeDroppable";
// Исправляем типизацию для DragDropContext
const DragDropContext = DragDropContextOriginal as any;
type AnswerDraggableListProps = {
questionId: string;
@ -14,21 +18,36 @@ export const AnswerDraggableList = ({
variants,
}: AnswerDraggableListProps) => {
const onDragEnd = ({ destination, source }: DropResult) => {
if (destination) {
// Проверяем наличие необходимых данных
if (!destination || !source) return;
// Проверяем, что индексы действительно изменились
if (destination.index === source.index) return;
// Проверяем валидность индексов
if (source.index < 0 || destination.index < 0) return;
try {
reorderQuestionVariants(questionId, source.index, destination.index);
} catch (error) {
console.error('Error reordering variants:', error);
// Здесь можно добавить уведомление пользователю об ошибке
}
};
return (
<DragDropContext onDragEnd={onDragEnd}>
<Droppable droppableId="droppable-answer-list">
<StrictModeDroppable droppableId="droppable-answer-list">
{(provided) => (
<Box ref={provided.innerRef} {...provided.droppableProps}>
<Box
ref={provided.innerRef}
{...provided.droppableProps}
>
{variants}
{provided.placeholder}
</Box>
)}
</Droppable>
</StrictModeDroppable>
</DragDropContext>
);
};

@ -3,6 +3,7 @@ import { ResultSettings } from "./ResultSettings";
import {
decrementCurrentStep,
incrementCurrentStep,
setCurrentStep,
} from "@root/quizes/actions";
import { Box, Button } from "@mui/material";
import ArrowLeft from "@icons/questionsPage/arrowLeft";

@ -1,10 +1,6 @@
import { logout } from "@api/auth";
import { activatePromocode } from "@api/promocode";
import type { Tariff } from "@frontend/kitui";
import { useToken } from "@frontend/kitui";
import { makeRequest } from "@api/makeRequest";
import ArrowLeft from "@icons/questionsPage/arrowLeft";
import type { GetTariffsResponse } from "@model/tariff";
import {
Box,
Button,
@ -12,15 +8,11 @@ import {
IconButton,
Modal,
Paper,
Select,
Typography,
useMediaQuery,
useTheme,
MenuItem,
} from "@mui/material";
import { clearQuizData } from "@root/quizes/store";
import { cleanAuthTicketData } from "@root/ticket";
import { clearUserData, useUserStore } from "@root/user";
import { useUserStore } from "@root/user";
import { LogoutButton } from "@ui_kit/LogoutButton";
import { useDomainDefine } from "@utils/hooks/useDomainDefine";
import { enqueueSnackbar } from "notistack";
@ -34,16 +26,14 @@ import { createTariffElements } from "./tariffsUtils/createTariffElements";
import { currencyFormatter } from "./tariffsUtils/currencyFormatter";
import { useWallet, setCash } from "@root/cash";
import { handleLogoutClick } from "@utils/HandleLogoutClick";
import { getDiscounts } from "@api/discounts";
import { cartApi } from "@api/cart";
import { getUser } from "@api/user";
import { getTariffs } from "@api/tariff";
import type { Discount } from "@model/discounts";
import { Other } from "./pages/Other";
import { ModalRequestCreate } from "./ModalRequestCreate";
import { cancelCC, useCC } from "@/stores/cc";
import { NavSelect } from "./NavSelect";
import { useTariffs } from '@utils/hooks/useTariffs';
import { useDiscounts } from '@utils/hooks/useDiscounts';
const StepperText: Record<string, string> = {
day: "Тарифы на время",
@ -59,9 +49,11 @@ function TariffPage() {
const isMobile = useMediaQuery(theme.breakpoints.down(600));
const userId = useUserStore((state) => state.userId);
const navigate = useNavigate();
const [tariffs, setTariffs] = useState<Tariff[]>([]);
const [user, setUser] = useState();
const [discounts, setDiscounts] = useState<Discount[]>([]);
const user = useUserStore((state) => state.customerAccount);
const a = useUserStore((state) => state.customerAccount); //c wallet
console.log("________________34563875693785692576_____________USERRRRRRR")
console.log(a)
const { data: discounts } = useDiscounts(userId);
const [isRequestCreate, setIsRequestCreate] = useState(false);
const [openModal, setOpenModal] = useState({});
const { cashString, cashCop, cashRub } = useWallet();
@ -70,56 +62,20 @@ function TariffPage() {
const [promocodeField, setPromocodeField] = useState<string>("");
const cc = useCC(store => store.cc)
const getTariffsList = async (): Promise<Tariff[]> => {
const tariffsList: Tariff[] = [];
let page = 2
const [tariffsResponse, tariffsResponseError] = await getTariffs(page - 1);
console.log(tariffsResponse)
if (tariffsResponseError || !tariffsResponse) {
return tariffsList;
}
tariffsList.push(...tariffsResponse.tariffs);
const { data: tariffs, error: tariffsError, isLoading: tariffsLoading } = useTariffs();
for (page; page <= tariffsResponse.totalPages; page += 1) {
const [tariffsResult] = await getTariffs(page);
if (tariffsResult) {
tariffsList.push(...tariffsResult.tariffs);
}
}
return tariffsList;
};
console.log("________34563875693785692576_____ TARIFFS")
console.log(tariffs)
useEffect(() => {
const get = async () => {
const [user, userError] = await getUser();
if (userError) {
return;
}
const tariffsList = await getTariffsList();
if (userId) {
const [discounts] = await getDiscounts(userId);
if (discounts?.length) {
setDiscounts(discounts);
}
}
setUser(user);
setTariffs(tariffsList);
if (a) {
let cs = currencyFormatter.format(Number(user.wallet.cash) / 100);
let cc = Number(user.wallet.cash);
let cr = Number(user.wallet.cash) / 100;
setCash(cs, cc, cr);
};
get();
}, []);
}
}, [a]);
useEffect(() => {
if (cc) {
@ -203,32 +159,17 @@ console.log(tariffsResponse)
if (error) {
enqueueSnackbar(error);
return;
}
enqueueSnackbar(greetings);
if (!userId) {
return;
}
const [discounts, discountsError] = await getDiscounts(userId);
if (discountsError) {
throw new Error(discountsError);
}
if (discounts?.length) {
setDiscounts(discounts);
}
}
const startRequestCreate = () => {
setIsRequestCreate(true)
}
if (!a) return null;
return (
<>
<Container
@ -346,7 +287,7 @@ console.log(tariffsResponse)
selectedItem={selectedItem}
content={[
{
title: `Убрать логотип “PenaQuiz”`,
title: `Убрать логотип "PenaQuiz"`,
onClick: () => setSelectedItem("hide")
},
{
@ -446,7 +387,7 @@ export const inCart = () => {
localStorage.setItem("saveCart", "[]");
}
};
const outCart = (cart: string[]) => {
export const outCart = (cart: string[]) => {
//Сделаем муторно и подольше, зато при прерывании сессии данные потеряются минимально
if (cart.length > 0) {
cart.forEach(async (id: string) => {

@ -19,6 +19,8 @@ export const createTariffElements = (
) => {
console.log("start work createTariffElements")
console.log("filteredTariffs ", filteredTariffs)
console.log("user ", user)
console.log("user.isUserNko, ", user.isUserNko)
const tariffElements = filteredTariffs
.filter((tariff) => tariff.privileges.length > 0)
.map((tariff, index) => {
@ -27,7 +29,7 @@ export const createTariffElements = (
discounts,
user.wallet.spent,
[],
user.isUserNko,
user.status === "nko",
user.userId,
);

@ -11,58 +11,59 @@ import AutoIcon8 from "@/assets/quiz-templates/auto/auto-8.jpg";
import AutoIcon9 from "@/assets/quiz-templates/auto/auto-9.jpg";
import AutoIcon10 from "@/assets/quiz-templates/auto/auto-10.jpg";
import { isTestServer } from "@/utils/hooks/useDomainDefine";
export const AUTO_TEMPLATES: Category = {
categoryType: "Auto",
category: "Авто",
templates: [
{
quizId: "eb885519-d9c2-41a5-a69c-6105d2bd9bef",
quizId: isTestServer ? "b1b0ed51-e2de-4b48-a8ca-d55e42b290ca" : "eb885519-d9c2-41a5-a69c-6105d2bd9bef",
title: "Узнайте, что у вас с машиной",
picture: AutoIcon1,
},
{
quizId: "68f080e2-ae70-4a1a-be09-05c3decea592",
quizId: isTestServer ? "037f6f16-58e9-4854-a3fd-ccbdaa2ef901" : "68f080e2-ae70-4a1a-be09-05c3decea592",
title: "Узнай стоимость и сроки выкупа своего автомобиля",
picture: AutoIcon2,
},
{
quizId: "446a5e79-8f10-4fb0-aa0f-165e3fbd8d36",
quizId: isTestServer ? "f5eadfa3-9cfc-4429-9854-380f5240fbbe" : "446a5e79-8f10-4fb0-aa0f-165e3fbd8d36",
title: "Автошкола «Руль в Руки»",
picture: AutoIcon3,
},
{
quizId: "f30c7d80-852e-405d-8308-a124636b5ffa",
quizId: isTestServer ? "1f4d6841-9ee6-43ba-9d3d-929bbf2a5252" : "f30c7d80-852e-405d-8308-a124636b5ffa",
title: "Узнайте, в какой компании выгодней КАСКО и ОСАГО",
picture: AutoIcon4,
},
{
quizId: "e200c96f-9c7a-4859-9bd2-65e42a6450b3",
quizId: isTestServer ? "dc5d523f-3922-4407-883c-22fc07f440d6" : "e200c96f-9c7a-4859-9bd2-65e42a6450b3",
title:
"Пройди тест, чтобы рассчитать стоимость необходимых детейлинг услуг",
picture: AutoIcon5,
},
{
quizId: "824c4553-ecb5-43e8-9b62-efc4844b01a8",
quizId: isTestServer ? "1e3dd6a6-34fb-44fc-9583-fd85de35b553" : "824c4553-ecb5-43e8-9b62-efc4844b01a8",
title: "Онлайн-калькулятор шиномонтажных услуг",
picture: AutoIcon6,
},
{
quizId: "42423a16-1159-4c5c-bb45-4e9940ab6098",
quizId: isTestServer ? "15a14d9a-7afc-44f8-b162-eddcc327911a" : "42423a16-1159-4c5c-bb45-4e9940ab6098",
title: "Калькулятор расчёта стоимости тонировки автомобиля",
picture: AutoIcon7,
},
{
quizId: "a0dfe680-30ff-4cac-91a5-28eb79889b68",
quizId: isTestServer ? "5c65c221-ac6d-4544-9f93-222b5790310b" : "a0dfe680-30ff-4cac-91a5-28eb79889b68",
title: "Рассчитайте стоимость проката премиум-автомобиля за 3 минуты",
picture: AutoIcon8,
},
{
quizId: "18145500-1fdd-4814-9607-8775fb1a5ea7",
quizId: isTestServer ? "bec775f0-2e0a-47b2-8f74-4ab03ee0b29e" : "18145500-1fdd-4814-9607-8775fb1a5ea7",
title: "Безопасное автокресло для вашего ребенка",
picture: AutoIcon9,
},
{
quizId: "63aa090c-8943-4a50-a10a-be394e75188b",
quizId: isTestServer ? "81a7b7e5-3045-4a5a-a850-b05d53d4bc82" : "63aa090c-8943-4a50-a10a-be394e75188b",
title: "Подберём для вас премиум-автомобиль для проката",
picture: AutoIcon10,
},

@ -11,59 +11,60 @@ import EductionIcon8 from "@/assets/quiz-templates/education/education-8.jpg";
import EductionIcon9 from "@/assets/quiz-templates/education/education-9.jpg";
import EductionIcon10 from "@/assets/quiz-templates/education/education-10.jpg";
import { isTestServer } from "@/utils/hooks/useDomainDefine";
export const EDUCATION_TEMPLATES: Category = {
categoryType: "Education",
category: "Образование",
templates: [
{
quizId: "27c10a81-f629-4af4-bdd0-2eb6c9cf10a8",
quizId: isTestServer ? "845cb5eb-bca8-495d-826f-e7b52a271b41" : "27c10a81-f629-4af4-bdd0-2eb6c9cf10a8",
title: "Получите приглашение на занятие по программированию для ребёнка",
picture: EductionIcon1,
},
{
quizId: "bf9aaa3b-5d2d-4f82-9d5e-74862d73d10e",
quizId: isTestServer ? "33042986-9ff3-408e-898b-13b53319cb08" : "bf9aaa3b-5d2d-4f82-9d5e-74862d73d10e",
title: "Научим играть любимую песню на фортепиано за 7 занятий",
picture: EductionIcon2,
},
{
quizId: "e2ed3948-6da2-48f4-86c7-42118b5abf85",
quizId: isTestServer ? "e7751cf8-467e-40e8-bd4d-0935ccab934b" : "e2ed3948-6da2-48f4-86c7-42118b5abf85",
title: "Подбери репетитора для своего ребёнка со скидкой в 20%",
picture: EductionIcon3,
},
{
quizId: "076d3d12-c8f0-442a-b918-7f6085daa3ec",
quizId: isTestServer ? "a9e40faa-4cd5-495e-8812-acc0dde2dee2" : "076d3d12-c8f0-442a-b918-7f6085daa3ec",
title: "Обратная связь о вебинаре",
picture: EductionIcon4,
},
{
quizId: "9914fe9c-19b4-47b1-aef8-a3c8e44f4c4c",
quizId: isTestServer ? "ab3fb1bc-afc8-4cf1-b77a-3fcff361d5be" : "9914fe9c-19b4-47b1-aef8-a3c8e44f4c4c",
title: "Хотите выучить английский?",
picture: EductionIcon5,
},
{
quizId: "ec9c252e-ea2c-489a-809d-27522b7c1972",
quizId: isTestServer ? "a2900f7b-cf24-4a9b-b5da-f7f99dbd8a1b" : "ec9c252e-ea2c-489a-809d-27522b7c1972",
title:
"Ответьте на 4 вопроса и узнайте, куда записать ребенка чтобы развивать его таланты",
picture: EductionIcon6,
},
{
quizId: "45acb5b0-1dca-45fe-aaa0-88895bd5b237",
quizId: isTestServer ? "" : "45acb5b0-1dca-45fe-aaa0-88895bd5b237",
title: "Поделитесь мнением о конференции",
picture: EductionIcon7,
},
{
quizId: "a9f17936-30c8-41ff-84d4-668840e02b56",
quizId: isTestServer ? "" : "a9f17936-30c8-41ff-84d4-668840e02b56",
title: "Научитесь красиво петь и управлять своим голосом",
picture: EductionIcon8,
},
{
quizId: "51c4d927-4d27-405d-ab7e-6c2707418017",
quizId: isTestServer ? "" : "51c4d927-4d27-405d-ab7e-6c2707418017",
title: "Узнайте, подойдёт ли вам профессия «Разработчик Phyton»?",
categoryDescription: "(С ветвлением)",
picture: EductionIcon9,
},
{
quizId: "6063ee99-3188-43aa-89bc-895d90b08628",
quizId: isTestServer ? "e6dc608c-055a-44bd-ba2e-6cb185b378fe" : "6063ee99-3188-43aa-89bc-895d90b08628",
title: "Проверьте своё знание английского языка",
categoryDescription: "(С ветвлением)",
picture: EductionIcon10,

@ -21,125 +21,126 @@ import HealthIcon18 from "@/assets/quiz-templates/health/health-18.jpg";
import HealthIcon19 from "@/assets/quiz-templates/health/health-19.jpg";
import HealthIcon20 from "@/assets/quiz-templates/health/health-20.jpg";
import { isTestServer } from "@/utils/hooks/useDomainDefine";
export const HEALTH_TEMPLATES: Category = {
categoryType: "Health",
category: "Здоровье и уход",
templates: [
{
quizId: "294c9c27-a189-4aa1-b792-a4d4612c99bf",
quizId: isTestServer ? "1927cf61-d80c-431c-8a04-4abca7c84b1e" : "294c9c27-a189-4aa1-b792-a4d4612c99bf",
title: "Узнайте, сколько будет стоить ваш маникюр",
categoryDescription: "Косметология",
picture: HealthIcon1,
},
{
quizId: "89fc7b57-9a13-4889-9e70-9d08714085f5",
quizId: isTestServer ? "cdb28a49-4bd4-411f-be8c-bc4bcdd577ab" : "89fc7b57-9a13-4889-9e70-9d08714085f5",
title: "Узнайте стоимость услуг косметолога в Казани",
categoryDescription: "Косметология",
picture: HealthIcon2,
},
{
quizId: "425c75c7-9412-485e-930f-3ae65f517fab",
quizId: isTestServer ? "6e6e8039-6d5e-4bc2-983a-f0e39f4b91c8" : "425c75c7-9412-485e-930f-3ae65f517fab",
title:
"Узнайте, как правильно ухаживать за вашим типом кожи в домашних условиях",
categoryDescription: "Косметология",
picture: HealthIcon3,
},
{
quizId: "99461154-6296-4c8c-930d-2b1809f221cd",
quizId: isTestServer ? "1dcec3e5-5bfc-481a-bf80-5a1ca8941e89" : "99461154-6296-4c8c-930d-2b1809f221cd",
title: "Какая косметологическая процедура вам нужна?",
categoryDescription: "Косметология",
picture: HealthIcon4,
},
{
quizId: "cbf6a8d4-538a-4edf-9477-062a15361b04",
quizId: isTestServer ? "3cf82c7a-44c9-49d0-bbeb-97a84f6ebe8f" : "cbf6a8d4-538a-4edf-9477-062a15361b04",
title: "5 вопросов до улыбки вашей мечты",
categoryDescription: "Стоматология",
picture: HealthIcon5,
},
{
quizId: "017d9d5c-57a8-4eca-95c1-11db847a0e18",
quizId: isTestServer ? "3520c146-3cd9-43c6-9ef2-42571ff06a3e" : "017d9d5c-57a8-4eca-95c1-11db847a0e18",
title:
"Пройдите небольшой опрос, и узнайте, какая процедура у стоматолога вам нужна",
categoryDescription: "Стоматология",
picture: HealthIcon6,
},
{
quizId: "162cb4f1-ab0a-49c4-b773-16932700f871",
quizId: isTestServer ? "aaa50e95-cd8c-4458-b82e-0139174d85ee" : "162cb4f1-ab0a-49c4-b773-16932700f871",
title: "Какой врач мне нужен?",
picture: HealthIcon7,
},
{
quizId: "c851276b-505d-492b-9acb-5cd85e6fe3a7",
quizId: isTestServer ? "d81b56a0-0961-41ca-8816-cc391bf75efb" : "c851276b-505d-492b-9acb-5cd85e6fe3a7",
title: "Психологическая помощь",
categoryDescription: "Психолог",
picture: HealthIcon8,
},
{
quizId: "2fa1d438-72ac-49b2-95b6-73a8c9d8347a",
quizId: isTestServer ? "f4a0e414-b739-4a2e-8001-3de6eb1304c3" : "2fa1d438-72ac-49b2-95b6-73a8c9d8347a",
title: "Ищешь психолога?",
categoryDescription: "Психолог",
picture: HealthIcon9,
},
{
quizId: "b0b30965-ec43-4718-8a1f-2ae35f932a61",
quizId: isTestServer ? "f10774b0-23f6-4525-a2fd-b3ffd9d59cce" : "b0b30965-ec43-4718-8a1f-2ae35f932a61",
title: "Подбор медицинского центра для лечебного массажа",
categoryDescription: "Массаж",
picture: HealthIcon10,
},
{
quizId: "722aff37-d247-4341-9908-412e41f9d7cd",
quizId: isTestServer ? "418d735e-8134-4742-963b-8fdf392aebd3" : "722aff37-d247-4341-9908-412e41f9d7cd",
title: "Исследование рынка мобильных приложений для здоровья",
picture: HealthIcon11,
},
{
quizId: "f0d800bc-2df0-42a6-8457-5c7759021854",
quizId: isTestServer ? "63552fb8-1586-4f14-a7c7-b75736294a87" : "f0d800bc-2df0-42a6-8457-5c7759021854",
title: "Выполним стрижки и окрашивания любой сложности",
categoryDescription: "Косметология",
picture: HealthIcon12,
},
{
quizId: "f88e2eb6-66e6-41ba-9d3d-1d7fe69d30d8",
quizId: isTestServer ? "4f4c6b83-a73c-4dbe-8776-ab93a073503d" : "f88e2eb6-66e6-41ba-9d3d-1d7fe69d30d8",
title: "Массажный салон «Промято» в Ярославле",
categoryDescription: "Массаж",
picture: HealthIcon13,
},
{
quizId: "9b2d47e8-d45f-48b7-a7fd-1c9c35edab17",
quizId: isTestServer ? "d47812bc-b7ac-4325-9eb1-496f1e60ab2c" : "9b2d47e8-d45f-48b7-a7fd-1c9c35edab17",
title: "Подбери себе направление в йоге",
categoryDescription: "Йога",
picture: HealthIcon14,
},
{
quizId: "8f6a1b3f-27fc-4e1c-a117-f67867e5df65",
quizId: isTestServer ? "bcd65cdd-07e7-480f-b305-596b815d1bb9" : "8f6a1b3f-27fc-4e1c-a117-f67867e5df65",
title: "Подберите за 2 минуты рацион готового питания",
categoryDescription: "Питание",
picture: HealthIcon15,
},
{
quizId: "73ff039f-3e93-4412-80ab-749f54c9bafa",
quizId: isTestServer ? "07f118c8-84fb-473a-9c83-357246fecaf1" : "73ff039f-3e93-4412-80ab-749f54c9bafa",
title: "Рассчитайте стоимость установки грудных имплантов",
picture: HealthIcon16,
},
{
quizId: "2b4be94e-3505-41ae-85bb-c6c4a4d1bcd4",
quizId: isTestServer ? "ecba00e5-3990-4501-8b7a-fbee50383625" : "2b4be94e-3505-41ae-85bb-c6c4a4d1bcd4",
title: "Не знаете, как выбрать очки? Подберите оправу под свои параметры",
categoryDescription: "Зрение",
picture: HealthIcon17,
},
{
quizId: "28b133a5-0e6a-46b9-bd6b-81a44b808341",
quizId: isTestServer ? "1315b676-2abb-49e6-a959-89e2963bbe53" : "28b133a5-0e6a-46b9-bd6b-81a44b808341",
title: "Санаторий в Подмосковье для пожилых людей",
categoryDescription: "Санаторий",
picture: HealthIcon18,
},
{
quizId: "88a8e952-1475-4052-b99a-bbb7eb31249c",
quizId: isTestServer ? "d439fa6a-e13b-4a38-9bea-9414ee82c9fd" : "88a8e952-1475-4052-b99a-bbb7eb31249c",
title: "Свежие блюда своими руками. 15 минут и готово",
categoryDescription: "Питание",
picture: HealthIcon19,
},
{
quizId: "6baf144a-7401-442a-a513-6bc5aa3f1a6a",
quizId: isTestServer ? "de88083b-f02d-4a53-8afa-2c646d6aa588" : "6baf144a-7401-442a-a513-6bc5aa3f1a6a",
title: "Рассчитайте стоимость отдыха в лучшей бане Москвы",
picture: HealthIcon20,
},

@ -11,58 +11,59 @@ import ProductionIcon8 from "@/assets/quiz-templates/production/production-8.jpg
import ProductionIcon9 from "@/assets/quiz-templates/production/production-9.jpg";
import ProductionIcon10 from "@/assets/quiz-templates/production/production-10.jpg";
import { isTestServer } from "@/utils/hooks/useDomainDefine";
export const PRODUCTION_TEMPLATES: Category = {
categoryType: "Production",
category: "Производство",
templates: [
{
quizId: "14859665-e8ea-4e4a-b381-af88179f8ba3",
quizId: isTestServer ? "2745eb4a-e592-4319-9e6c-4e3f7b9503d2" : "14859665-e8ea-4e4a-b381-af88179f8ba3",
title: "Рассчитайте стоимость постельного белья",
picture: ProductionIcon1,
},
{
quizId: "39cb17b6-10df-4107-abb8-6726d4845cbf",
quizId: isTestServer ? "1f18bf94-24c7-4f08-8362-e7efd2923359" : "39cb17b6-10df-4107-abb8-6726d4845cbf",
title: "Ответьте на 4 вопроса и подберите межкомнатную дверь",
picture: ProductionIcon2,
},
{
quizId: "21b125ed-0213-4a3c-bd30-1a75b3953f4a",
quizId: isTestServer ? "75a52c54-9ebf-4785-bd11-8c432125005a" : "21b125ed-0213-4a3c-bd30-1a75b3953f4a",
title: "Узнай стоимость производства и монтажа металлических ворот",
picture: ProductionIcon3,
},
{
quizId: "ed1a01f4-9497-4a79-adac-8f4fbf7f26f5",
quizId: isTestServer ? "2bc49b0b-e356-43bb-ac4e-37d135c48b2d" : "ed1a01f4-9497-4a79-adac-8f4fbf7f26f5",
title: "Заполните анкету, чтобы заказать изготовление ювелирного изделия",
picture: ProductionIcon4,
},
{
quizId: "c94834f8-dd3a-43a0-8d40-6ebae4f475ed",
quizId: isTestServer ? "38880384-214d-4ac5-95f7-8ceb2f6060b5" : "c94834f8-dd3a-43a0-8d40-6ebae4f475ed",
title: "Идеальный пол для любого помещения",
picture: ProductionIcon5,
},
{
quizId: "35ccb5b5-f4d2-4bbc-b172-5984356e7cfb",
quizId: isTestServer ? "d8189d8a-eb1d-41d7-9aee-62db91bd0ee0" : "35ccb5b5-f4d2-4bbc-b172-5984356e7cfb",
title: "Рассчитайте стоимость изготовления зеркала",
picture: ProductionIcon6,
},
{
quizId: "e89d3758-2cfb-4566-9eb2-733c1c11ea03",
quizId: isTestServer ? "a1c7240c-af97-4405-9724-f02155e140df" : "e89d3758-2cfb-4566-9eb2-733c1c11ea03",
title: "Подбери лучшие кеды",
picture: ProductionIcon7,
},
{
quizId: "26f00205-8373-4d00-bd93-7ced6cd0f509",
quizId: isTestServer ? "2031fe03-4f3e-4144-8cbf-26715b54d973" : "26f00205-8373-4d00-bd93-7ced6cd0f509",
title: "Идеальная кровать для вашего ребенка",
picture: ProductionIcon8,
},
{
quizId: "4cc7cacf-30a9-4571-9319-dd186b915624",
quizId: isTestServer ? "46824071-75e7-4988-b157-960471ad7234" : "4cc7cacf-30a9-4571-9319-dd186b915624",
title:
"Рассчитайте стоимость кухни ручной работы из Италии с доставкой в Россию",
picture: ProductionIcon9,
},
{
quizId: "0d839f24-53e8-4dbd-9d9b-c57ac8e53a9c",
quizId: isTestServer ? "df3aff58-afb1-4876-a97a-2d792373894e" : "0d839f24-53e8-4dbd-9d9b-c57ac8e53a9c",
title: "Узнайте примерную стоимость индивидуального пошива одежды",
picture: ProductionIcon10,
},

@ -11,62 +11,63 @@ import RealEstateIcon8 from "@/assets/quiz-templates/real-estate/real-estate-8.j
import RealEstateIcon9 from "@/assets/quiz-templates/real-estate/real-estate-9.jpg";
import RealEstateIcon10 from "@/assets/quiz-templates/real-estate/real-estate-10.jpg";
import { isTestServer } from "@/utils/hooks/useDomainDefine";
export const REAL_ESTATE_TEMPLATES: Category = {
categoryType: "RealEstate",
category: "Недвижимость",
templates: [
{
quizId: "d3930e95-ae95-4e2f-b9f9-79b929c2e1e6",
quizId: isTestServer ? "af9ed905-3947-4396-a9d9-a3b233451349" : "d3930e95-ae95-4e2f-b9f9-79b929c2e1e6",
title: "Рассчитайте стоимость каркасного дома своей мечты",
categoryDescription: "Строительство и ремонт",
picture: RealEstateIcon1,
},
{
quizId: "4e488b9b-d273-4f1c-b729-991fcbc006cd",
quizId: isTestServer ? "67b2c10a-a1f8-4eee-82a6-3aa7cd938566" : "4e488b9b-d273-4f1c-b729-991fcbc006cd",
title: "Краткосрочная аренда коммерческих помещений",
categoryDescription: "Аренда",
picture: RealEstateIcon2,
},
{
quizId: "84605c72-ce1d-49fb-a40e-7ed2ab96ac7d",
quizId: isTestServer ? "eacc428a-b724-4c26-9573-a179f23aef81" : "84605c72-ce1d-49fb-a40e-7ed2ab96ac7d",
title: "Подберем новостройку под ваши критерии",
picture: RealEstateIcon3,
},
{
quizId: "ab701ab8-b8ad-4f45-a1ef-f0ab5357a587",
quizId: isTestServer ? "b77623d9-b73e-4eab-9137-6cd3e3722bfa" : "ab701ab8-b8ad-4f45-a1ef-f0ab5357a587",
title: "15 лучших предложений от застройщиков в Москве",
picture: RealEstateIcon4,
},
{
quizId: "a5998d6c-c055-4702-bfc7-e1185fffa6c6",
quizId: isTestServer ? "8ebca441-50f2-4ed8-a648-fd35a86976e9" : "a5998d6c-c055-4702-bfc7-e1185fffa6c6",
title: "Подберем идеальное жильё в Риме",
picture: RealEstateIcon5,
},
{
quizId: "bfbf97f2-3eba-4386-a794-4fa8f5825ac1",
quizId: isTestServer ? "1df0c5b6-5d15-427f-8972-651b0a8e67d7" : "bfbf97f2-3eba-4386-a794-4fa8f5825ac1",
title: "Подбери уютный коттедж для отдыха в Подмосковье за 1 минуту",
picture: RealEstateIcon6,
},
{
quizId: "1b6ce902-0568-43c2-90a1-55dec710cb4f",
quizId: isTestServer ? "2857158a-18e0-4de7-bf0f-f1915ad95db1" : "1b6ce902-0568-43c2-90a1-55dec710cb4f",
title: "Среди сотен новостроек подберём для вас самые подходящие",
picture: RealEstateIcon7,
},
{
quizId: "0dfa128f-8c2b-4519-8cf4-05f9171979e1",
quizId: isTestServer ? "df32e587-660c-4248-8fd3-c6e9b1752ae0" : "0dfa128f-8c2b-4519-8cf4-05f9171979e1",
title: "Рассчитайте стоимость бронирования клуба для мероприятий",
categoryDescription: "Aренда",
picture: RealEstateIcon8,
},
{
quizId: "8c4c8e3d-19cb-4c55-8952-558b877245bd",
quizId: isTestServer ? "9151a489-0d31-4442-a868-db0779322697" : "8c4c8e3d-19cb-4c55-8952-558b877245bd",
title:
"Запишитесь на консультацию и получите каталог объектов в перспективных районах Дубая",
categoryDescription: "Услуги риелтора",
picture: RealEstateIcon9,
},
{
quizId: "36ebbe5d-4d85-453d-b5d2-51cdf7f95327",
quizId: isTestServer ? "6bcee3c8-951f-4745-8858-2b2cd6f6d282" : "36ebbe5d-4d85-453d-b5d2-51cdf7f95327",
title:
"Строим дома за 90 дней вместе со всеми коммуникациями и электричеством",
categoryDescription: "Строительство и ремонт",

@ -11,59 +11,60 @@ import RepairIcon8 from "@/assets/quiz-templates/repair/repair-8.jpg";
import RepairIcon9 from "@/assets/quiz-templates/repair/repair-9.jpg";
import RepairIcon10 from "@/assets/quiz-templates/repair/repair-10.jpg";
import { isTestServer } from "@/utils/hooks/useDomainDefine";
export const REPAIR_TEMPLATES: Category = {
categoryType: "Repair",
category: "Ремонт",
templates: [
{
quizId: "556760d9-652b-4ff1-91d5-3dc629650882",
quizId: isTestServer ? "d15ca3f0-8d59-4ac3-b168-6ac3246a22bb" : "556760d9-652b-4ff1-91d5-3dc629650882",
title: "Капитальный ремонт квартир с фиксированной ценой",
picture: RepairIcon1,
},
{
quizId: "8f034581-71fb-467e-82dd-a415d4b8d73c",
quizId: isTestServer ? "62295ce9-58ad-42c5-9827-e3b180c8c4f7" : "8f034581-71fb-467e-82dd-a415d4b8d73c",
title: "Натяжные потолки с гарантией 25 лет",
picture: RepairIcon2,
},
{
quizId: "fcb8c47b-f409-400c-b3d5-66657755f885",
quizId: isTestServer ? "d46febb0-3e79-4b83-8d7f-d104991a9359" : "fcb8c47b-f409-400c-b3d5-66657755f885",
title: "Рассчитайте стоимость пластиковых окон",
picture: RepairIcon3,
},
{
quizId: "7544a8d3-ff03-491d-9189-1433fe307ad0",
quizId: isTestServer ? "d473b5a6-4d70-49d3-bacb-35ce21cd88fe" : "7544a8d3-ff03-491d-9189-1433fe307ad0",
title: "Рассчитайте стоимость установки тёплого пола",
picture: RepairIcon4,
},
{
quizId: "dcf8bd1d-4c3f-4d1a-9efa-3d25991068f9",
quizId: isTestServer ? "a1bba994-a733-4817-a9bb-c64a68d670c8" : "dcf8bd1d-4c3f-4d1a-9efa-3d25991068f9",
title:
"Рассчитайте стоимость лестницы под ключ по вашим параметрам всего за одну минуту",
picture: RepairIcon5,
},
{
quizId: "2a921839-e5c8-45aa-afca-703d0dad8fad",
quizId: isTestServer ? "ad2fd24a-c28d-469c-95ea-78626fc51719" : "2a921839-e5c8-45aa-afca-703d0dad8fad",
title:
"Ответьте на 5 вопросов и рассчитайте стоимость вентиляции с монтажом под объект",
picture: RepairIcon6,
},
{
quizId: "ed13de01-f803-456a-b237-3644c808a0a1",
quizId: isTestServer ? "831303b3-aa9d-4115-936e-c46b899dd9b0" : "ed13de01-f803-456a-b237-3644c808a0a1",
title: "Узнайте стоимость освещения вашего объекта",
picture: RepairIcon7,
},
{
quizId: "8d05e910-df1f-4ad3-9679-c0c3f7b7e575",
quizId: isTestServer ? "48c57689-3999-4f52-90ef-b26232fc400d" : "8d05e910-df1f-4ad3-9679-c0c3f7b7e575",
title: "Узнайте стоимость кухни на заказ",
picture: RepairIcon8,
},
{
quizId: "9cabba56-2861-40dc-8f33-800745c3c949",
quizId: isTestServer ? "b61ca69f-d0e1-4754-ab91-2ec56fa45ca3" : "9cabba56-2861-40dc-8f33-800745c3c949",
title: "Узнай стоимость дизайна интерьера под ключ",
picture: RepairIcon9,
},
{
quizId: "1c0eb1ad-ed3e-43f9-bcba-f094d13fef5b",
quizId: isTestServer ? "bc6aad09-b0e0-419c-87d8-6dd9770cc12e" : "1c0eb1ad-ed3e-43f9-bcba-f094d13fef5b",
title:
"Требуется штукатурка? Узнайте примерную стоимость работ и материалов.",
picture: RepairIcon10,

@ -11,57 +11,58 @@ import ResearchIcon8 from "@/assets/quiz-templates/research/research-8.jpg";
import ResearchIcon9 from "@/assets/quiz-templates/research/research-9.jpg";
import ResearchIcon10 from "@/assets/quiz-templates/research/research-10.jpg";
import { isTestServer } from "@/utils/hooks/useDomainDefine";
export const RESEARCH_TEMPLATES: Category = {
categoryType: "Research",
category: "Исследовательские",
templates: [
{
quizId: "1b356222-e762-4f3d-87e5-4c3d6c0a9467",
quizId: isTestServer ? "14f05b7d-abd1-4069-83ef-52e5fb748592" : "1b356222-e762-4f3d-87e5-4c3d6c0a9467",
title: "Общественные настроения. Социальное самочувствие граждан",
picture: ResearchIcon1,
},
{
quizId: "7e901bea-6774-48b7-b31f-b62fd21ac88f",
quizId: isTestServer ? "b06f995a-7b35-493d-9614-a57cbbeae619" : "7e901bea-6774-48b7-b31f-b62fd21ac88f",
title: "Социальные институты и проблемы общества",
picture: ResearchIcon2,
},
{
quizId: "2570ccef-563c-4d8e-a052-d6ad142fb789",
quizId: isTestServer ? "47a72329-e3fe-4164-94d2-87ec8471de39" : "2570ccef-563c-4d8e-a052-d6ad142fb789",
title: "Уровень жизни населения",
picture: ResearchIcon3,
},
{
quizId: "b9394ed2-25e0-4e55-9d2a-9577856e903d",
quizId: isTestServer ? "54b0eccc-2ba2-4f48-8947-98449478a56f" : "b9394ed2-25e0-4e55-9d2a-9577856e903d",
title: "Проблемы семьи и семейные отношения",
picture: ResearchIcon4,
},
{
quizId: "922088b6-9e02-4a0f-b6af-a7150781d4eb",
quizId: isTestServer ? "e7c67cc4-6e80-4c78-81d0-62669274fd3e" : "922088b6-9e02-4a0f-b6af-a7150781d4eb",
title: "Здоровье и здравоохранение",
picture: ResearchIcon5,
},
{
quizId: "528ef773-2da5-4988-b687-b393d687ed00",
quizId: isTestServer ? "8c155f0c-9025-4779-be2c-17119a49fd40" : "528ef773-2da5-4988-b687-b393d687ed00",
title: "Религия и Церковь",
picture: ResearchIcon6,
},
{
quizId: "8887c07c-831f-40c6-9bf7-951ab09546da",
quizId: isTestServer ? "afa35959-9df4-4ff8-9b3c-9a6ede888931" : "8887c07c-831f-40c6-9bf7-951ab09546da",
title: "Трудоустройство молодежи",
picture: ResearchIcon7,
},
{
quizId: "850fde64-0462-40f7-992e-44fd0177e3b7",
quizId: isTestServer ? "75a252ec-b9e6-4764-acef-5d490a170e0b" : "850fde64-0462-40f7-992e-44fd0177e3b7",
title: "Культура и ценности",
picture: ResearchIcon8,
},
{
quizId: "2c6ba86a-6c86-47b2-b71c-3c4ebaf29fbb",
quizId: isTestServer ? "18ca6609-5390-45ea-81e0-b6d3d26277bf" : "2c6ba86a-6c86-47b2-b71c-3c4ebaf29fbb",
title: "Наука и технологии",
picture: ResearchIcon9,
},
{
quizId: "7ccd26ff-ccf5-4d6c-a148-1612a970211e",
quizId: isTestServer ? "cf3e02e5-b5d7-43d2-9ea3-888112387695" : "7ccd26ff-ccf5-4d6c-a148-1612a970211e",
title: "Бизнес и предпринимательство",
picture: ResearchIcon10,
},

@ -11,67 +11,68 @@ import ServiceIcon10 from "@/assets/quiz-templates/services/service-10.jpg";
import ServiceIcon11 from "@/assets/quiz-templates/services/service-11.jpg";
import type { Category } from "../Template";
import { isTestServer } from "@/utils/hooks/useDomainDefine";
export const SERVICE_TEMPLATES: Category = {
categoryType: "Services",
category: "Услуги",
templates: [
{
quizId: "a3490800-1ad3-4944-bb9c-32189d36b75c",
quizId: isTestServer ? "a055f7c1-34eb-4969-bf6a-1c701e1217b1" : "a3490800-1ad3-4944-bb9c-32189d36b75c",
title:
"Ответьте на 3 вопроса и узнайте, паспорт какой европейской страны вам подойдёт",
picture: ServiceIcon1,
},
{
quizId: "785fed83-6608-4029-ae22-6a26ce621e5f",
quizId: isTestServer ? "9fd1eff3-e342-4ae8-8e2a-2929c83af9cd" : "785fed83-6608-4029-ae22-6a26ce621e5f",
title:
"Ответьте на 7 вопросов, чтобы получить коммерческое предложение от маркетолога",
picture: ServiceIcon2,
},
{
quizId: "dfa3733f-66ce-4335-b83a-2c6511cbd1ce",
quizId: isTestServer ? "baf9468d-31b8-422f-9baa-efe2b00ac2af" : "dfa3733f-66ce-4335-b83a-2c6511cbd1ce",
title:
"Ответьте на пару вопросов, чтобы найти свой индивидуальный стиль одежды",
picture: ServiceIcon3,
},
{
quizId: "8bf582a9-0a66-4f7b-bc0f-3c2f656c7449",
quizId: isTestServer ? "934e9f8f-ab9c-443b-8693-ce132baae9d1" : "8bf582a9-0a66-4f7b-bc0f-3c2f656c7449",
title: "Обменяйте рубли на валюту с комиссией 0%",
picture: ServiceIcon4,
},
{
quizId: "206ba071-afe9-4ee0-a722-a24a4f592679",
quizId: isTestServer ? "16cdb992-4338-417b-a8f5-0684c062e2cb" : "206ba071-afe9-4ee0-a722-a24a4f592679",
title: "Рассчитайте стоимость уборки вашей квартиры",
picture: ServiceIcon5,
},
{
quizId: "6938ff93-52eb-4296-86bf-fe5aa3fddabf",
quizId: isTestServer ? "c3c5bf13-8498-4c35-9b58-1b9d6114fc47" : "6938ff93-52eb-4296-86bf-fe5aa3fddabf",
title: "Забронируйте номер в зоогостинице для своего любимого питомца",
picture: ServiceIcon6,
},
{
quizId: "5262bc69-1ea0-446c-a16f-e929b6190e6d",
quizId: isTestServer ? "" : "5262bc69-1ea0-446c-a16f-e929b6190e6d",
title: "Организуем перевозку под ключ",
picture: ServiceIcon7,
},
{
quizId: "9f8015f7-07fc-4acb-92dd-6e00505884cc",
quizId: isTestServer ? "37bc6554-1634-4b0c-8d95-abf589c8f56d" : "9f8015f7-07fc-4acb-92dd-6e00505884cc",
title: "Рассчитайте стоимость ремонта пластиковых окон за 3 минуты",
picture: ServiceIcon8,
},
{
quizId: "259749bf-a54f-4a8e-ab5a-4cd0862d7504",
quizId: isTestServer ? "5ff47f56-2f50-42c8-af1e-936c8f63aca2" : "259749bf-a54f-4a8e-ab5a-4cd0862d7504",
title: "Поможем подобрать эскиз для татуировки",
picture: ServiceIcon9,
},
{
quizId: "e107c0cd-4fa1-4a8f-938a-10a329b6528d",
quizId: isTestServer ? "4fc3dd99-b818-40ac-81d4-75150308608e" : "e107c0cd-4fa1-4a8f-938a-10a329b6528d",
title: "Подбери себе лучшего юриста за 30 секунд",
categoryDescription: "Юр услуги",
picture: ServiceIcon10,
},
{
quizId: "ce7903b1-3dfb-4a28-a2a4-0b41af447ae1",
quizId: isTestServer ? "ad4adfed-8e82-4d77-8675-6c024ab492ad" : "ce7903b1-3dfb-4a28-a2a4-0b41af447ae1",
title: "Рассчитайте размер ипотечного кредитования, ответив на 4 вопроса",
categoryDescription: "Юр услуги",
picture: ServiceIcon11,

@ -10,58 +10,59 @@ import TourismIcon9 from "@/assets/quiz-templates/tourism/tourism-9.jpg";
import TourismIcon10 from "@/assets/quiz-templates/tourism/tourism-10.jpg";
import type { Category } from "../Template";
import { isTestServer } from "@/utils/hooks/useDomainDefine";
export const TOURISM_TEMPLATES: Category = {
categoryType: "Tourism",
category: "Туризм",
templates: [
{
quizId: "f7a2b3b8-2548-47d8-afb3-f2c69a3a0a81",
quizId: isTestServer ? "0d42bc16-c927-4d49-8764-deb4e4f14c4f" : "f7a2b3b8-2548-47d8-afb3-f2c69a3a0a81",
title: "Подбор туристической страховки",
picture: TourismIcon1,
},
{
quizId: "e0927ded-5c4c-4d45-a5ba-c2e938362ffa",
quizId: isTestServer ? "897e3908-28e3-494e-a0be-0e3a0264b946" : "e0927ded-5c4c-4d45-a5ba-c2e938362ffa",
title: "Оцените свои шансы на получение визы в США",
picture: TourismIcon2,
},
{
quizId: "23af97f4-0b8f-4d8b-8099-66ebef409ce1",
quizId: isTestServer ? "c4eec832-3e34-4486-b4db-e32812f924ea" : "23af97f4-0b8f-4d8b-8099-66ebef409ce1",
title: "Персональный тур с лучшими местами в Германии",
picture: TourismIcon3,
},
{
quizId: "ca3bd705-7d41-4ff1-ae4c-0b2d4a8faa30",
quizId: isTestServer ? "67fbd981-e77d-4f43-8e37-9b2e94dc4afa" : "ca3bd705-7d41-4ff1-ae4c-0b2d4a8faa30",
title: "Подберём лучший вариант тура под ваши критерии",
picture: TourismIcon4,
},
{
quizId: "5c2effd9-fe6a-40e6-9752-3f61dc20d6fa",
quizId: isTestServer ? "dd965dcf-aeca-4c39-8ee9-0dde372204b6" : "5c2effd9-fe6a-40e6-9752-3f61dc20d6fa",
title: "Выберем самый подходящий для вас тур в Грузию",
picture: TourismIcon5,
},
{
quizId: "b559a764-6f55-4dc2-a9c4-aecd8b96003c",
quizId: isTestServer ? "e8d98b38-fbda-486e-b69a-b036f74703c8" : "b559a764-6f55-4dc2-a9c4-aecd8b96003c",
title: "Бонжур, Сена! Подберём для вас тур по Франции",
picture: TourismIcon6,
},
{
quizId: "e33bf54b-9ad5-4cb9-b552-77148264d6af",
quizId: isTestServer ? "52018c8a-53e7-4d76-8fc3-4e3bd414ee3e" : "e33bf54b-9ad5-4cb9-b552-77148264d6af",
title: "Персональный тур в Египет с лучшими местами страны",
picture: TourismIcon7,
},
{
quizId: "c5815b1d-4991-4df2-ae14-8713d7f313b9",
quizId: isTestServer ? "740b5ee8-0ab4-441f-8f75-e22b3a61ab36" : "c5815b1d-4991-4df2-ae14-8713d7f313b9",
title: "Тур по местам России",
picture: TourismIcon8,
},
{
quizId: "a0a4dce8-43bb-4978-a802-96d384465df4",
quizId: isTestServer ? "5e0d7397-6e05-4f81-b24f-a625c2da12d3" : "a0a4dce8-43bb-4978-a802-96d384465df4",
title: "Подберём для вас тур с самыми красивыми местами мира",
picture: TourismIcon9,
},
{
quizId: "0749abc5-a352-41b9-85c3-db7541326f23",
quizId: isTestServer ? "65fdf9a0-d30d-4728-abe3-b9a64c965269" : "0749abc5-a352-41b9-85c3-db7541326f23",
title: "Выберем лучшие туристические места для вас",
picture: TourismIcon10,
},

@ -0,0 +1,59 @@
import { useState, MouseEvent, ReactNode } from "react";
import Info from "@icons/Info";
import { Paper, Popover, SxProps, Typography } from "@mui/material";
export const InfoPopover = ({
blink = false,
sx,
children = "подсказка"
}: {
blink?: boolean,
sx?: SxProps,
children?: ReactNode
}) => {
const [anchorEl, setAnchorEl] = useState<HTMLButtonElement | null>(null);
const handleClick = (event: MouseEvent<HTMLButtonElement>) => {
setAnchorEl(event.currentTarget);
};
const handleClose = () => {
setAnchorEl(null);
};
const open = Boolean(anchorEl);
const id = open ? "simple-popover" : undefined;
return (
<>
<Info
className={blink ? "blink" : ""}
onClick={handleClick}
sx={{p:0, height: "20px", ...sx}}
/>
<Popover
id={id}
open={open}
anchorEl={anchorEl}
onClose={handleClose}
anchorOrigin={{
vertical: "bottom",
horizontal: "left",
}}
>
<Paper
sx={{
p: "20px",
display: "flex",
justifyContent: "center",
alignItems: "center",
flexDirection: "column",
}}
>
{children}
</Paper>
</Popover>
</>
);
};

@ -31,6 +31,7 @@ export default function MenuItem({
px: 0,
pt: "5px",
pb: "3px",
whiteSpace: "break-spaces"
}}
>
<ListItemButton

@ -4,7 +4,7 @@ import PencilCircleIcon from "@icons/PencilCircleIcon";
import PuzzlePieceIcon from "@icons/PuzzlePieceIcon";
import TagIcon from "@icons/TagIcon";
import { quizSetupSteps } from "@model/quizSettings";
import { Box, IconButton, List, Typography, useTheme } from "@mui/material";
import { Box, IconButton, List, Typography, useMediaQuery, useTheme } from "@mui/material";
import { useQuizStore } from "@root/quizes/store";
import { useState } from "react";
import MenuItem from "../MenuItem";
@ -12,6 +12,7 @@ import { useCurrentQuiz } from "@root/quizes/hooks";
import { useLocation, useNavigate } from "react-router-dom";
import { setCurrentStep } from "@root/quizes/actions";
import { setTryShowAmoTokenExpiredDialog, updateNextStep } from "@root/uiTools/actions";
import AiPersonalizationIcon from "../../assets/icons/AiPersonalizationIcon";
const quizSettingsMenuItems = [
[TagIcon, "Дополнения"],
@ -31,6 +32,7 @@ export default function Sidebar({ changePage, disableCollapse }: SidebarProps) {
const currentStep = useQuizStore((state) => state.currentStep);
const quiz = useCurrentQuiz();
const { pathname } = useLocation();
const isMobile = useMediaQuery(theme.breakpoints.down(650));
const navigate = useNavigate();
const changeMenuItem = (index: number) => {
@ -47,17 +49,18 @@ export default function Sidebar({ changePage, disableCollapse }: SidebarProps) {
return (
<Box
id="Sidebar"
sx={{
backgroundColor: theme.palette.lightPurple.main,
minWidth: isMenuCollapsed ? "80px" : "230px",
width: isMenuCollapsed ? "80px" : "230px",
height: "calc(100vh - 80px)",
height: isMobile ? "100%" : "calc(100vh - 80px)",
display: "flex",
flexDirection: "column",
py: "19px",
transitionProperty: "width, min-width",
transitionDuration: "200ms",
overflow: "hidden",
overflow: "auto",
whiteSpace: "nowrap",
boxSizing: "border-box",
zIndex: 1,
@ -71,6 +74,7 @@ export default function Sidebar({ changePage, disableCollapse }: SidebarProps) {
mb: isMenuCollapsed ? "5px" : undefined,
alignItems: "center",
justifyContent: isMenuCollapsed ? "center" : undefined,
}}
>
{!isMenuCollapsed && (
@ -99,7 +103,7 @@ export default function Sidebar({ changePage, disableCollapse }: SidebarProps) {
</IconButton>
)}
</Box>
<List disablePadding>
<List disablePadding id="momobibilele">
{quizSetupSteps.map((menuItem, index) => {
const Icon = menuItem.sidebarIcon;
@ -168,6 +172,20 @@ export default function Sidebar({ changePage, disableCollapse }: SidebarProps) {
/>
}
/>
<MenuItem
onClick={() => {
navigate("/personalization-ai");
setCurrentStep(17);
setTryShowAmoTokenExpiredDialog(true);
}}
text={"Персонализация вопросов с помощью AI"}
isCollapsed={isMenuCollapsed}
isActive={pathname.startsWith("/personalization-ai")}
disabled={pathname.startsWith("/personalization-ai") ? false : quiz === undefined ? true : quiz?.config.type === null}
icon={
<AiPersonalizationIcon />
}
/>
<MenuItem
onClick={() => {
navigate("/integrations");

@ -22,7 +22,7 @@ export const SidebarModal = ({
onClick={handleClick}
sx={{
outline: "none",
overflow: "hidden",
overflow: "auto",
maxWidth: "230px",
maxHeight: "400px",
width: "100%",

@ -12,6 +12,7 @@ export default function TooltipClickInfo({ title }: { title: string }) {
const handleTooltipOpen = () => {
setOpen(true);
};
return (
<>
<ClickAwayListener onClickAway={handleTooltipClose}>
@ -19,14 +20,21 @@ export default function TooltipClickInfo({ title }: { title: string }) {
<Tooltip
PopperProps={{
disablePortal: true,
sx: {
"& .MuiTooltip-tooltip": {
fontSize: "14px",
padding: "12px",
maxWidth: "400px",
whiteSpace: "pre-line"
}
}
}}
placement="top"
onClose={handleTooltipClose}
open={open}
disableFocusListener
disableHoverListener
disableTouchListener
title={title}
onMouseEnter={handleTooltipOpen}
onMouseLeave={handleTooltipClose}
>
<IconButton onClick={handleTooltipOpen}>
<InfoIcon />

@ -6,6 +6,7 @@ import { useEffect } from "react";
import { redirect, useNavigate, useSearchParams } from "react-router-dom";
import { calcTimeOfReadyPayCart, cancelPayCartProcess, startPayCartProcess, useNotEnoughMoneyAmount } from "@/stores/notEnoughMoneyAmount";
import { startCC } from "@/stores/cc";
import { setEditQuizId, setCurrentStep } from "@root/quizes/actions";
export const useAfterPay = () => {
const navigate = useNavigate();
@ -17,7 +18,19 @@ export const useAfterPay = () => {
const purpose = searchParams.get("purpose");
const paymentUserId = searchParams.get("userid");
const currentCC = searchParams.get("cc");
const wayback = searchParams.get("wayback");
// Обработка wayback параметра
useEffect(() => {
if (wayback) {
const quizId = wayback.split("_")[1];
if (quizId) {
setEditQuizId(Number(quizId));
setCurrentStep(17); // Шаг для персонализации AI
navigate("/personalization-ai");
}
}
}, [wayback, navigate]);
useEffect(() => {
//Звёзды сошлись, будем оплачивать корзину
@ -25,7 +38,7 @@ export const useAfterPay = () => {
if (purpose === "paycart") {
setSearchParams({}, { replace: true });
if (currentCC) { startCC() }
if (currentCC) startCC()
(async () => {
//Проверяем можем ли мы оплатить корзину здесь и сейчас

@ -0,0 +1,10 @@
import useSWR from 'swr';
import { getDiscounts } from '@api/discounts';
import type { Discount } from '@model/discounts';
export const useDiscounts = (userId: string | null) => {
return useSWR<Discount[]>(
userId ? `discounts/${userId}` : null,
() => getDiscounts(userId!).then(([data]) => data)
);
};

@ -11,3 +11,6 @@ export function useDomainDefine(): { isTestServer: boolean } {
return { isTestServer };
}
const host = window.location.hostname;
export const isTestServer = host.includes("s");

@ -0,0 +1,19 @@
import useSWR from 'swr';
import { getTariffs } from '@/api/tariff';
import type { GetTariffsResponse, Tariff } from '@frontend/kitui';
export const useTariffs = () => {
const { data, error, isLoading } = useSWR<Tariff[]>('tariffs', async () => {
const [response] = await getTariffs();
if (response?.tariffs) {
return response.tariffs;
}
return [];
});
return {
data,
error,
isLoading
};
};

@ -0,0 +1,7 @@
import useSWR from 'swr';
import { getUser } from '@api/user';
import type { User } from '@frontend/kitui';
export const useUser = () => {
return useSWR<User>('user', getUser);
};

@ -1,9 +1,7 @@
import { useEffect, useLayoutEffect, useRef } from "react";
import { createUserAccount, devlog } from "@frontend/kitui";
import { isAxiosError } from "axios";
import { makeRequest } from "@api/makeRequest";
import type { UserAccount } from "@frontend/kitui";
import { setUserAccount } from "@/stores/user";
@ -20,10 +18,12 @@ export const useUserAccountFetcher = <T = UserAccount>({
}) => {
const onNewUserAccountRef = useRef(onNewUserAccount);
const onErrorRef = useRef(onError);
useLayoutEffect(() => {
onNewUserAccountRef.current = onNewUserAccount;
onErrorRef.current = onError;
}, [onError, onNewUserAccount]);
useEffect(() => {
if (!userId) return;
const controller = new AbortController();

4289
yarn.lock

File diff suppressed because it is too large Load Diff