Compare commits
80 Commits
Author | SHA1 | Date | |
---|---|---|---|
be993e2206 | |||
48ae1359ac | |||
7686c9e326 | |||
2d087ae113 | |||
43839c5879 | |||
effc4cbea9 | |||
89b55d4d3e | |||
25deb4a0f4 | |||
5ad3a810ef | |||
5ff3d7ae16 | |||
8604daf6a4 | |||
a7c78e57e7 | |||
a8d6db9f2d | |||
c396752ce6 | |||
929e6047cf | |||
6465652f82 | |||
9bb69f1ab2 | |||
ac7c001738 | |||
eb5f8168c8 | |||
918b6bf145 | |||
61c0357adc | |||
5ea6d6638a | |||
87d4c9d53d | |||
98bf92466b | |||
595f4b041b | |||
2efdd0b422 | |||
a83214acd4 | |||
f0a977031d | |||
f5a4bdc36f | |||
8255fe6908 | |||
2540dd8079 | |||
ff2569dea8 | |||
dd2a96f948 | |||
7f024bcf78 | |||
cc6d78935c | |||
14bfc6750f | |||
65db81af2d | |||
fbb6025512 | |||
1e8a50077b | |||
e7121cb06a | |||
c2d79c04cc | |||
6273e62e66 | |||
0e0376686d | |||
350b28cb6b | |||
ab1e221ada | |||
262337272f | |||
e2e391325b | |||
aa917fe6ba | |||
c6cb130d5b | |||
4eda624080 | |||
22b61bed44 | |||
1b32719ef7 | |||
b9154061ec | |||
93af4a0707 | |||
39b0e04293 | |||
1e657569a4 | |||
62762c955c | |||
9336508260 | |||
cf1fce88cb | |||
459553056d | |||
8e6f756f78 | |||
f4ea6b15a7 | |||
fff9560f9c | |||
0cdc7797f8 | |||
5bba710b91 | |||
277a8c3076 | |||
de8f66a837 | |||
62a9237925 | |||
b3537381b7 | |||
2f843b4c96 | |||
3d35ef033a | |||
99e394390c | |||
32e153518c | |||
ff57344ee7 | |||
1ec8f34238 | |||
28a1ee950e | |||
f5cf23fdef | |||
05ffc45c18 | |||
75c7fb55e7 | |||
fa1a907222 |
26
.gitea/workflows/deployProd.yml
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
name: Deploy
|
||||||
|
run-name: ${{ gitea.actor }} build image and push to container registry
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- 'main'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
CreateImage:
|
||||||
|
runs-on: [skeris]
|
||||||
|
uses: https://gitea.pena/PenaDevops/actions.git/.gitea/workflows/build-image.yml@v1.1.6-p
|
||||||
|
with:
|
||||||
|
runner: skeris
|
||||||
|
secrets:
|
||||||
|
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
|
||||||
|
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
|
||||||
|
DeployService:
|
||||||
|
runs-on: [frontprod]
|
||||||
|
needs: CreateImage
|
||||||
|
uses: https://gitea.pena/PenaDevops/actions.git/.gitea/workflows/deploy.yml@v1.1.4-p7
|
||||||
|
with:
|
||||||
|
runner: hubprod
|
||||||
|
actionid: ${{ gitea.run_id }}
|
||||||
|
|
||||||
|
|
26
.gitea/workflows/deployStaging.yml
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
name: Deploy
|
||||||
|
run-name: ${{ gitea.actor }} build image and push to container registry
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- 'staging'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
CreateImage:
|
||||||
|
runs-on: [skeris]
|
||||||
|
uses: http://gitea.pena/PenaDevops/actions.git/.gitea/workflows/build-image.yml@v1.1.6-p
|
||||||
|
with:
|
||||||
|
runner: skeris
|
||||||
|
secrets:
|
||||||
|
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
|
||||||
|
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
|
||||||
|
DeployService:
|
||||||
|
runs-on: [frontstaging]
|
||||||
|
needs: CreateImage
|
||||||
|
uses: http://gitea.pena/PenaDevops/actions.git/.gitea/workflows/deploy.yml@v1.1.4-p7
|
||||||
|
with:
|
||||||
|
runner: frontstaging
|
||||||
|
actionid: ${{ gitea.run_id }}
|
||||||
|
|
||||||
|
|
14
.gitea/workflows/lint.yml
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
name: Lint
|
||||||
|
run-name: ${{ gitea.actor }} produce linting
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- 'dev'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
Lint:
|
||||||
|
runs-on: [hubstaging]
|
||||||
|
uses: http://gitea.pena/PenaDevops/actions.git/.gitea/workflows/lint.yml@v1.1.0
|
||||||
|
with:
|
||||||
|
runner: hubstaging
|
@ -1,38 +0,0 @@
|
|||||||
include:
|
|
||||||
- project: "devops/pena-continuous-integration"
|
|
||||||
file: "/templates/docker/build-template.gitlab-ci.yml"
|
|
||||||
- project: "devops/pena-continuous-integration"
|
|
||||||
file: "/templates/docker/deploy-template.gitlab-ci.yml"
|
|
||||||
- project: "devops/pena-continuous-integration"
|
|
||||||
file: "/templates/docker/service-discovery.gitlab-ci.yml"
|
|
||||||
stages:
|
|
||||||
- build
|
|
||||||
- deploy
|
|
||||||
- service-discovery
|
|
||||||
build-app:
|
|
||||||
extends: .build_template
|
|
||||||
tags:
|
|
||||||
- frontbuild
|
|
||||||
variables:
|
|
||||||
DOCKER_BUILD_PATH: "./Dockerfile"
|
|
||||||
STAGING_BRANCH: "staging"
|
|
||||||
PRODUCTION_BRANCH: "main"
|
|
||||||
|
|
||||||
deploy-to-staging:
|
|
||||||
extends: .deploy_template
|
|
||||||
rules:
|
|
||||||
- if: "$CI_COMMIT_BRANCH == $STAGING_BRANCH"
|
|
||||||
tags:
|
|
||||||
- front
|
|
||||||
- staging
|
|
||||||
|
|
||||||
deploy-to-prod:
|
|
||||||
extends: .deploy_template
|
|
||||||
rules:
|
|
||||||
- if: "$CI_COMMIT_BRANCH == $PRODUCTION_BRANCH"
|
|
||||||
tags:
|
|
||||||
- front
|
|
||||||
- prod
|
|
||||||
|
|
||||||
service-discovery:
|
|
||||||
extends: .sd_artefacts_template
|
|
1
.npmrc
Normal file
@ -0,0 +1 @@
|
|||||||
|
@frontend:registry=http://gitea.pena/api/packages/skeris/npm/
|
5
.vscode/extensions.json
vendored
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"recommendations": [
|
||||||
|
"godrix.svgr-preview"
|
||||||
|
]
|
||||||
|
}
|
1
.yarnrc
@ -1 +0,0 @@
|
|||||||
"@frontend:registry" "https://penahub.gitlab.yandexcloud.net/api/v4/packages/npm/"
|
|
13
Containerfile
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
FROM gitea.pena/penadevops/container-images/node:main as build
|
||||||
|
|
||||||
|
WORKDIR /usr/app
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
|
||||||
|
RUN npm install --force && yarn cache clean
|
||||||
|
RUN psstat.sh "npm run build"
|
||||||
|
|
||||||
|
FROM gitea.pena/penadevops/container-images/nginx:main as result
|
||||||
|
WORKDIR /usr/share/nginx/html
|
||||||
|
COPY --from=build /usr/app/build/ /usr/share/nginx/html
|
||||||
|
COPY hub.conf /etc/nginx/conf.d/default.conf
|
19
Dockerfile
@ -1,19 +0,0 @@
|
|||||||
FROM penahub.gitlab.yandexcloud.net:5050/devops/dockerhub-backup/node as build
|
|
||||||
|
|
||||||
RUN apk update && rm -rf /var/cache/apk/*
|
|
||||||
WORKDIR /usr/app
|
|
||||||
COPY . .
|
|
||||||
|
|
||||||
RUN npm config set -- //penahub.gitlab.yandexcloud.net/api/v4/packages/npm/:_authToken=glpat-JL_7wSM1QpW7xGfd-oWX
|
|
||||||
RUN npm config set -- //penahub.gitlab.yandexcloud.net/api/v4/projects/43/packages/npm/:_authToken=glpat-JL_7wSM1QpW7xGfd-oWX
|
|
||||||
RUN npm config set @frontend:registry https://penahub.gitlab.yandexcloud.net/api/v4/packages/npm/
|
|
||||||
RUN yarn config set '//penahub.gitlab.yandexcloud.net/api/v4/packages/npm/:_authToken' "glpat-JL_7wSM1QpW7xGfd-oWX"
|
|
||||||
RUN yarn config set '//penahub.gitlab.yandexcloud.net/api/v4/projects/:_authToken' "glpat-JL_7wSM1QpW7xGfd-oWX"
|
|
||||||
RUN yarn install --ignore-scripts --non-interactive && yarn cache clean
|
|
||||||
RUN yarn build
|
|
||||||
|
|
||||||
|
|
||||||
FROM penahub.gitlab.yandexcloud.net:5050/devops/dockerhub-backup/nginx as result
|
|
||||||
WORKDIR /usr/share/nginx/html
|
|
||||||
COPY --from=build /usr/app/build/ /usr/share/nginx/html
|
|
||||||
COPY hub.conf /etc/nginx/conf.d/default.conf
|
|
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({
|
export default defineConfig({
|
||||||
e2e: {
|
e2e: {
|
||||||
baseUrl: 'http://localhost:3000',
|
baseUrl: 'http://localhost:3000',
|
||||||
viewportWidth: 1440,
|
viewportWidth: 1280,
|
||||||
viewportHeight: 900,
|
viewportHeight: 720,
|
||||||
supportFile: false,
|
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',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
75
cypress/e2e/personalization.cy.ts
Normal file
@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
28
cypress/support/commands.ts
Normal file
@ -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>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -1,8 +1,7 @@
|
|||||||
version: "3"
|
|
||||||
services:
|
services:
|
||||||
squiz:
|
squiz:
|
||||||
container_name: squiz
|
container_name: squiz
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
image: $CI_REGISTRY_IMAGE/main:$CI_COMMIT_REF_SLUG.$CI_PIPELINE_ID
|
image: gitea.pena/squiz/frontpanel/main:$GITHUB_RUN_NUMBER
|
||||||
hostname: squiz
|
hostname: squiz
|
||||||
tty: true
|
tty: true
|
||||||
|
@ -1,15 +1,7 @@
|
|||||||
version: "3"
|
|
||||||
services:
|
services:
|
||||||
squiz:
|
squiz:
|
||||||
container_name: squiz
|
container_name: squiz
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
image: $CI_REGISTRY_IMAGE/staging:$CI_COMMIT_REF_SLUG.$CI_PIPELINE_ID
|
image: gitea.pena/squiz/frontpanel/staging:$GITHUB_RUN_NUMBER
|
||||||
networks:
|
|
||||||
- marketplace_penahub_frontend
|
|
||||||
labels:
|
|
||||||
com.pena.domains: squiz.pena.digital
|
|
||||||
hostname: squiz
|
hostname: squiz
|
||||||
tty: true
|
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'
|
||||||
|
}
|
||||||
|
};
|
20860
package-lock.json
generated
Normal file
11
package.json
@ -6,7 +6,7 @@
|
|||||||
"@craco/craco": "^7.0.0",
|
"@craco/craco": "^7.0.0",
|
||||||
"@emotion/react": "^11.10.5",
|
"@emotion/react": "^11.10.5",
|
||||||
"@emotion/styled": "^11.10.5",
|
"@emotion/styled": "^11.10.5",
|
||||||
"@frontend/kitui": "^1.0.86",
|
"@frontend/kitui": "^1.0.108",
|
||||||
"@frontend/squzanswerer": "^1.0.57",
|
"@frontend/squzanswerer": "^1.0.57",
|
||||||
"@mui/icons-material": "^5.10.14",
|
"@mui/icons-material": "^5.10.14",
|
||||||
"@mui/material": "^5.10.14",
|
"@mui/material": "^5.10.14",
|
||||||
@ -25,7 +25,6 @@
|
|||||||
"@types/react-slick": "^0.23.13",
|
"@types/react-slick": "^0.23.13",
|
||||||
"axios": "^1.5.1",
|
"axios": "^1.5.1",
|
||||||
"country-flag-emoji-polyfill": "^0.1.8",
|
"country-flag-emoji-polyfill": "^0.1.8",
|
||||||
"cypress-file-upload": "^5.0.8",
|
|
||||||
"cytoscape": "^3.26.0",
|
"cytoscape": "^3.26.0",
|
||||||
"cytoscape-popper": "^2.0.0",
|
"cytoscape-popper": "^2.0.0",
|
||||||
"date-fns": "^3.0.6",
|
"date-fns": "^3.0.6",
|
||||||
@ -69,9 +68,10 @@
|
|||||||
"build": "craco build",
|
"build": "craco build",
|
||||||
"test": "craco test",
|
"test": "craco test",
|
||||||
"eject": "craco eject",
|
"eject": "craco eject",
|
||||||
"cypress:open": "cypress open",
|
|
||||||
"code:format": "prettier --write --ignore-unknown",
|
"code:format": "prettier --write --ignore-unknown",
|
||||||
"prepare": "husky install"
|
"prepare": "husky install",
|
||||||
|
"cypress:open": "cypress open",
|
||||||
|
"cypress:run": "cypress run"
|
||||||
},
|
},
|
||||||
"browserslist": {
|
"browserslist": {
|
||||||
"production": [
|
"production": [
|
||||||
@ -86,13 +86,12 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@emoji-mart/data": "^1.1.2",
|
"@emoji-mart/data": "^1.2.1",
|
||||||
"@emoji-mart/react": "^1.1.1",
|
"@emoji-mart/react": "^1.1.1",
|
||||||
"@types/cytoscape-popper": "^2.0.4",
|
"@types/cytoscape-popper": "^2.0.4",
|
||||||
"@types/react-beautiful-dnd": "^13.1.4",
|
"@types/react-beautiful-dnd": "^13.1.4",
|
||||||
"@types/react-cytoscapejs": "^1.2.4",
|
"@types/react-cytoscapejs": "^1.2.4",
|
||||||
"craco-alias": "^3.0.1",
|
"craco-alias": "^3.0.1",
|
||||||
"cypress": "^13.6.1",
|
|
||||||
"husky": "^8.0.3",
|
"husky": "^8.0.3",
|
||||||
"lint-staged": "^15.2.0",
|
"lint-staged": "^15.2.0",
|
||||||
"prettier": "^3.1.1"
|
"prettier": "^3.1.1"
|
||||||
|
@ -35,6 +35,7 @@ const { DesignPage } = lazily(() => import("./pages/DesignPage/DesignPage"));
|
|||||||
const { IntegrationsPage } = lazily(() => import("./pages/IntegrationsPage/IntegrationsPage"));
|
const { IntegrationsPage } = lazily(() => import("./pages/IntegrationsPage/IntegrationsPage"));
|
||||||
const { QuizAnswersPage } = lazily(() => import("./pages/QuizAnswersPage/QuizAnswersPage"));
|
const { QuizAnswersPage } = lazily(() => import("./pages/QuizAnswersPage/QuizAnswersPage"));
|
||||||
const ChatImageNewWindow = lazy(() => import("@ui_kit/FloatingSupportChat/ChatImageNewWindow"));
|
const ChatImageNewWindow = lazy(() => import("@ui_kit/FloatingSupportChat/ChatImageNewWindow"));
|
||||||
|
const PersonalizationAI = lazy(() => import("./pages/PersonalizationAI/PersonalizationAI"));
|
||||||
let params = new URLSearchParams(document.location.search);
|
let params = new URLSearchParams(document.location.search);
|
||||||
const isTest = Boolean(params.get("test"))
|
const isTest = Boolean(params.get("test"))
|
||||||
|
|
||||||
@ -60,6 +61,12 @@ const routeslink = [
|
|||||||
sidebar: true,
|
sidebar: true,
|
||||||
footer: true,
|
footer: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "/personalization-ai",
|
||||||
|
page: PersonalizationAI,
|
||||||
|
header: true,
|
||||||
|
sidebar: true,
|
||||||
|
},
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
const LazyLoading = ({ children, fallback }: SuspenseProps) => (
|
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,5 +1,6 @@
|
|||||||
import { QuestionKeys } from "@/pages/IntegrationsPage/IntegrationsModal/Amo/types";
|
import { QuestionKeys } from "@/pages/IntegrationsPage/IntegrationsModal/Amo/types";
|
||||||
import { makeRequest } from "@api/makeRequest";
|
import { makeRequest } from "@api/makeRequest";
|
||||||
|
import { useToken } from "@frontend/kitui";
|
||||||
import { parseAxiosError } from "@utils/parse-error";
|
import { parseAxiosError } from "@utils/parse-error";
|
||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
|
|
||||||
@ -41,7 +42,9 @@ export const getAccount = async (): Promise<[AccountResponse | null, string?]> =
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function useAmoAccount() {
|
export function useAmoAccount() {
|
||||||
return useSWR("amoAccount", () =>
|
const token = useToken();
|
||||||
|
|
||||||
|
return useSWR(token ? "amoAccount" : null, () =>
|
||||||
makeRequest<void, AccountResponse>({
|
makeRequest<void, AccountResponse>({
|
||||||
method: "GET",
|
method: "GET",
|
||||||
url: `${API_URL}/account`,
|
url: `${API_URL}/account`,
|
||||||
@ -363,9 +366,9 @@ export const removeAmoAccount = async (): Promise<[void | null, string?]> => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
export const getFields = async ( pagination: PaginationRequest ): Promise<[FieldsResponse | null, string?]> => {
|
export const getFields = async (pagination: PaginationRequest): Promise<[FieldsResponse | null, string?]> => {
|
||||||
try {
|
try {
|
||||||
const fieldsResponse = await makeRequest<PaginationRequest, FieldsResponse>({
|
const fieldsResponse = await makeRequest<PaginationRequest, FieldsResponse>({
|
||||||
method: "GET",
|
method: "GET",
|
||||||
url: `${API_URL}/fields?page=${pagination.page}&size=${pagination.size}`,
|
url: `${API_URL}/fields?page=${pagination.page}&size=${pagination.size}`,
|
||||||
});
|
});
|
||||||
|
@ -33,11 +33,11 @@ export const makeRequest = async <TRequest = unknown, TResponse = unknown>(
|
|||||||
} catch (nativeError) {
|
} catch (nativeError) {
|
||||||
const error = nativeError as AxiosError;
|
const error = nativeError as AxiosError;
|
||||||
|
|
||||||
selectSendingMethod({
|
// if (window.location.hostname !== 'localhost') selectSendingMethod({
|
||||||
messageField: `status: ${error.response?.status}. Message ${(error.response?.data as ExtendedAxiosResponse)?.message}`,
|
// messageField: `status: ${error.response?.status}. Message ${(error.response?.data as ExtendedAxiosResponse)?.message}`,
|
||||||
isSnackbar: false,
|
// isSnackbar: false,
|
||||||
systemError: true
|
// systemError: true
|
||||||
});
|
// });
|
||||||
if (
|
if (
|
||||||
error.response?.status === 400 &&
|
error.response?.status === 400 &&
|
||||||
(error.response?.data as ExtendedAxiosResponse)?.message ===
|
(error.response?.data as ExtendedAxiosResponse)?.message ===
|
||||||
|
@ -148,8 +148,8 @@ export const addQuizImages = async (
|
|||||||
const name = image?.name ? transliterate(image?.name.replace(/\s/g, '_')) : "blob"
|
const name = image?.name ? transliterate(image?.name.replace(/\s/g, '_')) : "blob"
|
||||||
|
|
||||||
//Замена всех побелов на _
|
//Замена всех побелов на _
|
||||||
const renamedImage = new File([image], name)
|
const renamedImage = new File([image], name)
|
||||||
|
|
||||||
|
|
||||||
formData.append("quiz", quizId.toString());
|
formData.append("quiz", quizId.toString());
|
||||||
formData.append("image", renamedImage);
|
formData.append("image", renamedImage);
|
||||||
|
@ -1,13 +1,11 @@
|
|||||||
import { makeRequest } from "@api/makeRequest";
|
import { makeRequest } from "@api/makeRequest";
|
||||||
|
|
||||||
import { parseAxiosError } from "@utils/parse-error";
|
import { parseAxiosError } from "@utils/parse-error";
|
||||||
|
|
||||||
import type { GetTariffsResponse } from "@frontend/kitui";
|
import type { GetTariffsResponse } from "@frontend/kitui";
|
||||||
|
|
||||||
const API_URL = `${process.env.REACT_APP_DOMAIN}/strator/tariff`;
|
const API_URL = `${process.env.REACT_APP_DOMAIN}/strator/tariff`;
|
||||||
|
|
||||||
export const getTariffs = async (
|
export const getTariffs = async (
|
||||||
page: number,
|
page: number = 1,
|
||||||
): Promise<[GetTariffsResponse | null, string?]> => {
|
): Promise<[GetTariffsResponse | null, string?]> => {
|
||||||
try {
|
try {
|
||||||
const tariffs = await makeRequest<never, GetTariffsResponse>({
|
const tariffs = await makeRequest<never, GetTariffsResponse>({
|
||||||
@ -17,7 +15,6 @@ export const getTariffs = async (
|
|||||||
return [tariffs];
|
return [tariffs];
|
||||||
} catch (nativeError) {
|
} catch (nativeError) {
|
||||||
const [error] = parseAxiosError(nativeError);
|
const [error] = parseAxiosError(nativeError);
|
||||||
|
|
||||||
return [null, `Ошибка при получении списка тарифов. ${error}`];
|
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'
|
||||||
|
});
|
||||||
|
};
|
5
src/assets/icons/AiPersonalizationIcon.svg
Normal file
@ -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 |
12
src/assets/icons/AiPersonalizationIcon.tsx
Normal file
@ -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;
|
18
src/assets/icons/SmallAddPluse.tsx
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import { SvgIcon, SxProps } from "@mui/material";
|
||||||
|
|
||||||
|
export default function SmallAddPluse({ sx }: { sx: SxProps }) {
|
||||||
|
return (
|
||||||
|
<SvgIcon
|
||||||
|
sx={{
|
||||||
|
width: "11px",
|
||||||
|
height: "11px",
|
||||||
|
|
||||||
|
...sx
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg width="10" height="10" viewBox="0 0 10 10" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M4.518 9.612C4.398 9.612 4.296 9.576 4.212 9.504C4.14 9.42 4.104 9.318 4.104 9.198V5.454H0.414C0.294 5.454 0.192 5.418 0.108 5.346C0.036 5.262 0 5.16 0 5.04V4.464C0 4.344 0.036 4.248 0.108 4.176C0.192 4.092 0.294 4.05 0.414 4.05H4.104V0.414C4.104 0.294 4.14 0.198 4.212 0.126C4.296 0.0420001 4.398 0 4.518 0H5.148C5.268 0 5.364 0.0420001 5.436 0.126C5.52 0.198 5.562 0.294 5.562 0.414V4.05H9.27C9.39 4.05 9.486 4.092 9.558 4.176C9.642 4.248 9.684 4.344 9.684 4.464V5.04C9.684 5.16 9.642 5.262 9.558 5.346C9.486 5.418 9.39 5.454 9.27 5.454H5.562V9.198C5.562 9.318 5.52 9.42 5.436 9.504C5.364 9.576 5.268 9.612 5.148 9.612H4.518Z" fill="white" />
|
||||||
|
</svg>
|
||||||
|
</SvgIcon>
|
||||||
|
);
|
||||||
|
}
|
@ -1,6 +1,6 @@
|
|||||||
import { Box } from "@mui/material";
|
import { Box, type SxProps} from "@mui/material";
|
||||||
|
|
||||||
export default function Plus() {
|
export default function Plus(sx:SxProps) {
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
@ -9,6 +9,7 @@ export default function Plus() {
|
|||||||
display: "flex",
|
display: "flex",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
|
...sx
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
|
Before Width: | Height: | Size: 123 KiB After Width: | Height: | Size: 18 KiB |
Before Width: | Height: | Size: 1.3 MiB After Width: | Height: | Size: 21 KiB |
Before Width: | Height: | Size: 164 KiB After Width: | Height: | Size: 13 KiB |
Before Width: | Height: | Size: 861 KiB After Width: | Height: | Size: 18 KiB |
Before Width: | Height: | Size: 514 KiB After Width: | Height: | Size: 12 KiB |
Before Width: | Height: | Size: 642 KiB After Width: | Height: | Size: 15 KiB |
Before Width: | Height: | Size: 667 KiB After Width: | Height: | Size: 12 KiB |
Before Width: | Height: | Size: 107 KiB After Width: | Height: | Size: 17 KiB |
Before Width: | Height: | Size: 93 KiB After Width: | Height: | Size: 13 KiB |
Before Width: | Height: | Size: 600 KiB After Width: | Height: | Size: 13 KiB |
Before Width: | Height: | Size: 618 KiB After Width: | Height: | Size: 11 KiB |
Before Width: | Height: | Size: 442 KiB After Width: | Height: | Size: 16 KiB |
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 14 KiB |
Before Width: | Height: | Size: 82 KiB After Width: | Height: | Size: 13 KiB |
Before Width: | Height: | Size: 264 KiB After Width: | Height: | Size: 8.1 KiB |
Before Width: | Height: | Size: 600 KiB After Width: | Height: | Size: 15 KiB |
Before Width: | Height: | Size: 295 KiB After Width: | Height: | Size: 12 KiB |
Before Width: | Height: | Size: 645 KiB After Width: | Height: | Size: 12 KiB |
Before Width: | Height: | Size: 847 KiB After Width: | Height: | Size: 9.2 KiB |
Before Width: | Height: | Size: 746 KiB After Width: | Height: | Size: 14 KiB |
Before Width: | Height: | Size: 431 KiB After Width: | Height: | Size: 11 KiB |
Before Width: | Height: | Size: 752 KiB After Width: | Height: | Size: 12 KiB |
Before Width: | Height: | Size: 424 KiB After Width: | Height: | Size: 10 KiB |
Before Width: | Height: | Size: 584 KiB After Width: | Height: | Size: 11 KiB |
Before Width: | Height: | Size: 205 KiB After Width: | Height: | Size: 11 KiB |
Before Width: | Height: | Size: 517 KiB After Width: | Height: | Size: 16 KiB |
Before Width: | Height: | Size: 442 KiB After Width: | Height: | Size: 21 KiB |
Before Width: | Height: | Size: 209 KiB After Width: | Height: | Size: 8.4 KiB |
Before Width: | Height: | Size: 719 KiB After Width: | Height: | Size: 15 KiB |
Before Width: | Height: | Size: 132 KiB After Width: | Height: | Size: 20 KiB |
Before Width: | Height: | Size: 870 KiB After Width: | Height: | Size: 22 KiB |
Before Width: | Height: | Size: 516 KiB After Width: | Height: | Size: 9.4 KiB |
Before Width: | Height: | Size: 522 KiB After Width: | Height: | Size: 15 KiB |
Before Width: | Height: | Size: 310 KiB After Width: | Height: | Size: 9.0 KiB |
Before Width: | Height: | Size: 572 KiB After Width: | Height: | Size: 10 KiB |
Before Width: | Height: | Size: 758 KiB After Width: | Height: | Size: 16 KiB |
Before Width: | Height: | Size: 1013 KiB After Width: | Height: | Size: 16 KiB |
Before Width: | Height: | Size: 436 KiB After Width: | Height: | Size: 13 KiB |
Before Width: | Height: | Size: 571 KiB After Width: | Height: | Size: 18 KiB |
Before Width: | Height: | Size: 669 KiB After Width: | Height: | Size: 13 KiB |
Before Width: | Height: | Size: 425 KiB After Width: | Height: | Size: 13 KiB |
Before Width: | Height: | Size: 865 KiB After Width: | Height: | Size: 20 KiB |
Before Width: | Height: | Size: 378 KiB After Width: | Height: | Size: 4.8 KiB |
Before Width: | Height: | Size: 568 KiB After Width: | Height: | Size: 9.6 KiB |
Before Width: | Height: | Size: 550 KiB After Width: | Height: | Size: 6.7 KiB |
Before Width: | Height: | Size: 512 KiB After Width: | Height: | Size: 6.6 KiB |
Before Width: | Height: | Size: 410 KiB After Width: | Height: | Size: 14 KiB |
Before Width: | Height: | Size: 1.2 MiB After Width: | Height: | Size: 15 KiB |
Before Width: | Height: | Size: 351 KiB After Width: | Height: | Size: 9.4 KiB |
Before Width: | Height: | Size: 658 KiB After Width: | Height: | Size: 15 KiB |
Before Width: | Height: | Size: 242 KiB After Width: | Height: | Size: 25 KiB |
Before Width: | Height: | Size: 297 KiB After Width: | Height: | Size: 18 KiB |
Before Width: | Height: | Size: 698 KiB After Width: | Height: | Size: 14 KiB |
Before Width: | Height: | Size: 356 KiB After Width: | Height: | Size: 12 KiB |
Before Width: | Height: | Size: 1.0 MiB After Width: | Height: | Size: 15 KiB |
Before Width: | Height: | Size: 262 KiB After Width: | Height: | Size: 14 KiB |
Before Width: | Height: | Size: 788 KiB After Width: | Height: | Size: 18 KiB |
Before Width: | Height: | Size: 533 KiB After Width: | Height: | Size: 18 KiB |
Before Width: | Height: | Size: 656 KiB After Width: | Height: | Size: 17 KiB |
Before Width: | Height: | Size: 352 KiB After Width: | Height: | Size: 15 KiB |
Before Width: | Height: | Size: 972 KiB After Width: | Height: | Size: 17 KiB |
Before Width: | Height: | Size: 488 KiB After Width: | Height: | Size: 6.9 KiB |
Before Width: | Height: | Size: 592 KiB After Width: | Height: | Size: 10 KiB |
Before Width: | Height: | Size: 637 KiB After Width: | Height: | Size: 16 KiB |
Before Width: | Height: | Size: 771 KiB After Width: | Height: | Size: 22 KiB |
Before Width: | Height: | Size: 447 KiB After Width: | Height: | Size: 25 KiB |
Before Width: | Height: | Size: 1.3 MiB After Width: | Height: | Size: 23 KiB |
Before Width: | Height: | Size: 518 KiB After Width: | Height: | Size: 12 KiB |
Before Width: | Height: | Size: 580 KiB After Width: | Height: | Size: 16 KiB |
Before Width: | Height: | Size: 826 KiB After Width: | Height: | Size: 18 KiB |