use backend somewhere & refactor

This commit is contained in:
nflnkr 2023-11-13 21:04:51 +03:00
parent f7f1ce13ab
commit 0c6198dd79
26 changed files with 2132 additions and 1131 deletions

@ -41,6 +41,7 @@
"react-rnd": "^10.4.1",
"react-router-dom": "^6.6.2",
"react-scripts": "5.0.1",
"swr": "^2.2.4",
"typescript": "^4.4.2",
"use-debounce": "^9.0.4",
"web-vitals": "^2.1.0",

@ -5,40 +5,44 @@ import { DeleteQuizRequest, DeleteQuizResponse } from "model/quiz/delete";
import { EditQuizRequest, EditQuizResponse } from "model/quiz/edit";
import { GetQuizRequest, GetQuizResponse } from "model/quiz/get";
import { GetQuizListRequest, GetQuizListResponse } from "model/quiz/getList";
import { BackendQuiz } from "model/quiz/quiz";
import { RawQuiz } from "model/quiz/quiz";
const baseUrl = process.env.NODE_ENV === "production" ? "/squiz" : "https://squiz.pena.digital/squiz";
function createQuiz(body: CreateQuizRequest = defaultCreateQuizBody) {
return makeRequest<CreateQuizRequest, BackendQuiz>({
function createQuiz(body?: Partial<CreateQuizRequest>) {
return makeRequest<CreateQuizRequest, RawQuiz>({
url: `${baseUrl}/quiz/create`,
body,
body: { ...defaultCreateQuizBody, ...body },
method: "POST",
});
}
function getQuizList(body: GetQuizListRequest = defaultGetQuizListBody) {
return makeRequest<GetQuizListRequest, GetQuizListResponse>({
async function getQuizList(body?: Partial<GetQuizListRequest>) {
const response = await makeRequest<GetQuizListRequest, GetQuizListResponse>({
url: `${baseUrl}/quiz/getList`,
body,
method: "GET",
body: { ...defaultGetQuizListBody, ...body },
method: "POST",
});
return response.items;
}
function getQuiz(body: GetQuizRequest = defaultGetQuizBody) {
function getQuiz(body?: Partial<GetQuizRequest>) {
return makeRequest<GetQuizRequest, GetQuizResponse>({
url: `${baseUrl}/quiz/get`,
body,
body: { ...defaultGetQuizBody, ...body },
method: "GET",
});
}
function editQuiz(body: EditQuizRequest = defaultEditQuizBody) {
async function editQuiz(body: EditQuizRequest, signal?: AbortSignal) {
// await new Promise((resolve) => setTimeout(resolve, 1000));
return makeRequest<EditQuizRequest, EditQuizResponse>({
url: `${baseUrl}/quiz/edit`,
body,
method: "PATCH",
signal,
});
}
@ -86,18 +90,18 @@ const defaultCreateQuizBody: CreateQuizRequest = {
"fingerprinting": true,
"repeatable": true,
"note_prevented": true,
"mail_notifications": true,
"mail_notifications": false,
"unique_answers": true,
"name": "string",
"description": "string",
"config": "string",
"status": "string",
"status": "stop",
"limit": 0,
"due_to": 0,
"time_of_passing": 0,
"pausable": true,
"pausable": false,
"question_cnt": 0,
"super": true,
"super": false,
"group_id": 0,
};
@ -129,14 +133,6 @@ const defaultGetQuizBody: GetQuizRequest = {
};
const defaultGetQuizListBody: GetQuizListRequest = {
"limit": 0,
"limit": 100,
"offset": 0,
"from": 0,
"to": 0,
"search": "string",
"status": "string",
"deleted": true,
"archived": true,
"super": true,
"group_id": 0,
};

@ -11,6 +11,7 @@ import { HTML5Backend } from "react-dnd-html5-backend";
import { createRoot } from "react-dom/client";
import "./index.css";
import lightTheme from "./utils/themes/light";
import { SWRConfig } from "swr";
dayjs.locale("ru");
@ -20,14 +21,16 @@ const localeText = ruRU.components.MuiLocalizationProvider.defaultProps.localeTe
const root = createRoot(document.getElementById("root")!);
root.render(
<DndProvider backend={HTML5Backend}>
<LocalizationProvider dateAdapter={AdapterDayjs} adapterLocale="ru" localeText={localeText}>
<ThemeProvider theme={lightTheme}>
<SnackbarProvider>
<CssBaseline />
<App />
</SnackbarProvider>
</ThemeProvider>
</LocalizationProvider>
</DndProvider>
<SWRConfig value={{ revalidateOnFocus: false, shouldRetryOnError: false }}>
<DndProvider backend={HTML5Backend}>
<LocalizationProvider dateAdapter={AdapterDayjs} adapterLocale="ru" localeText={localeText}>
<ThemeProvider theme={lightTheme}>
<SnackbarProvider>
<CssBaseline />
<App />
</SnackbarProvider>
</ThemeProvider>
</LocalizationProvider>
</DndProvider>
</SWRConfig>
);

@ -1,18 +1,34 @@
export interface CreateQuizRequest {
fingerprinting: boolean;
repeatable: boolean;
note_prevented: boolean;
/** set true for save deviceId */
fingerprinting: boolean;
/** set true for allow user to repeat quiz */
repeatable: boolean;
/** set true for save statistic of incomplete quiz passing */
note_prevented: boolean;
/** set true for mail notification for each quiz passing */
mail_notifications: boolean;
unique_answers: boolean;
name: string;
description: string;
config: string;
status: string;
limit: number;
due_to: number;
time_of_passing: number;
pausable: boolean;
question_cnt: number;
super: boolean;
group_id: number;
/** set true for save statistics only for unique quiz passing */
unique_answers: boolean;
/** name of quiz. max 280 length */
name: string;
/** description of quiz */
description: string;
/** config of quiz. serialized json for rules of quiz flow */
config: string;
/** status of quiz. allow only '', 'draft', 'template', 'stop', 'start' */
status: "draft" | "template" | "stop" | "start";
/** limit is count of max quiz passing */
limit: number;
/** last time when quiz is valid. timestamp in seconds */
due_to: number;
/** seconds to pass quiz */
time_of_passing: number;
/** true if it is allowed for pause quiz */
pausable: boolean;
/** count of questions */
question_cnt: number;
/** set true if squiz realize group functionality */
super: boolean;
/** group of new quiz */
group_id: number;
}

@ -1,23 +1,66 @@
import { Quiz } from "./quiz";
export interface EditQuizRequest {
/** id of question for update */
id: number;
/** set true for storing fingerprints */
fp: boolean;
/** set true for allow to repeat quiz after passing */
rep: boolean;
/** set true for store unfinished passing */
note_prevented: boolean;
/** set true if we should send passing result on every passing */
mailing: boolean;
/** set true if we allow only one user quiz passing */
uniq: boolean;
name: string;
desc: string;
conf: string;
status: string;
/** new name of the quiz */
name?: string;
/** new descriptions of the quiz */
desc?: string;
/** new config of the quiz */
conf?: string;
/** new status. only draft,template,stop,start allowed */
status?: string;
/** max amount of quiz passing */
limit: number;
/** max time of quiz passing */
due_to: number;
/** max time to pass quiz */
time_of_passing: number;
/** allow to pause quiz to user */
pausable: boolean;
question_cnt: number;
super: boolean;
group_id: number;
/** count of questions */
question_cnt?: number;
/** set true if squiz realize group functionality */
super?: boolean;
/** group of new quiz */
group_id?: number;
}
export interface EditQuizResponse {
/** id of new version of question */
updated: number;
}
export function quizToEditQuizRequest(quiz: Quiz): EditQuizRequest {
return {
id: quiz.id,
fp: quiz.fingerprinting,
rep: quiz.repeatable,
note_prevented: quiz.note_prevented,
mailing: quiz.mail_notifications,
uniq: quiz.unique_answers,
name: quiz.name,
desc: quiz.description,
conf: JSON.stringify(quiz.config),
status: quiz.status,
limit: quiz.limit,
due_to: quiz.due_to,
time_of_passing: quiz.time_of_passing,
pausable: quiz.pausable,
question_cnt: quiz.question_cnt,
super: quiz.super,
group_id: quiz.group_id,
};
}

@ -1,17 +1,29 @@
import { RawQuiz } from "./quiz";
export interface GetQuizListRequest {
limit: number;
offset: number;
from: number;
to: number;
search: string;
status: string;
deleted: boolean;
archived: boolean;
super: boolean;
group_id: number;
/** max items on page */
limit?: number;
/** page number */
offset?: number;
/** start time of time period. timestamp in seconds */
from?: number;
/** end time of time period. timestamp in seconds */
to?: number;
/** string for fulltext search in titles of quizes */
search?: string;
/** allow only - draft, template, timeout, stop, start, offlimit */
status?: "" | "draft" | "template" | "timeout" | "stop" | "start" | "offlimit";
/** get deleted quizes */
deleted?: boolean;
/** get archived quizes */
archived?: boolean;
/** set true if squiz realize group functionality */
super?: boolean;
/** group of new quiz */
group_id?: number;
}
export interface GetQuizListResponse {
count: number;
items: unknown[]; // TODO
items: RawQuiz[];
}

@ -1,29 +1,134 @@
export interface BackendQuiz {
import { QuizConfig, defaultQuizConfig } from "@model/quizSettings";
export interface Quiz {
/** Id of created quiz */
id: number;
/** string id for customers */
qid: string;
/** true if quiz deleted */
deleted: boolean;
/** true if quiz archived */
archived: boolean;
/** set true for save deviceId */
fingerprinting: boolean;
/** set true for allow user to repeat quiz */
repeatable: boolean;
/** set true for save statistic of incomplete quiz passing */
note_prevented: boolean;
/** set true for mail notification for each quiz passing */
mail_notifications: boolean;
/** set true for save statistics only for unique quiz passing */
unique_answers: boolean;
/** name of quiz. max 280 length */
name: string;
/** description of quiz */
description: string;
config: string;
/** quiz config*/
config: QuizConfig;
/** status of quiz. allow only '', 'draft', 'template', 'stop', 'start' */
status: string;
/** limit is count of max quiz passing */
limit: number;
/** last time when quiz is valid. timestamp in seconds */
due_to: number;
/** seconds to pass quiz */
time_of_passing: number;
/** true if it is allowed for pause quiz */
pausable: boolean;
/** version of quiz */
version: number;
/** version comment to version of quiz */
version_comment: string;
/** array of previous versions of quiz */
parent_ids: number[];
created_at: string;
updated_at: string;
/** count of questions */
question_cnt: number;
/** count passings */
passed_count: number;
/** average time of passing */
average_time: number;
/** set true if squiz realize group functionality */
super: boolean;
/** group of new quiz */
group_id: number;
}
export interface RawQuiz {
/** Id of created quiz */
id: number;
/** string id for customers */
qid: string;
/** true if quiz deleted */
deleted: boolean;
/** true if quiz archived */
archived: boolean;
/** set true for save deviceId */
fingerprinting: boolean;
/** set true for allow user to repeat quiz */
repeatable: boolean;
/** set true for save statistic of incomplete quiz passing */
note_prevented: boolean;
/** set true for mail notification for each quiz passing */
mail_notifications: boolean;
/** set true for save statistics only for unique quiz passing */
unique_answers: boolean;
/** name of quiz. max 280 length */
name: string;
/** description of quiz */
description: string;
/** config of quiz. serialized json for rules of quiz flow */
config: string;
/** status of quiz. allow only '', 'draft', 'template', 'stop', 'start' */
status: string;
/** limit is count of max quiz passing */
limit: number;
/** last time when quiz is valid. timestamp in seconds */
due_to: number;
/** seconds to pass quiz */
time_of_passing: number;
/** true if it is allowed for pause quiz */
pausable: boolean;
/** version of quiz */
version: number;
/** version comment to version of quiz */
version_comment: string;
/** array of previous versions of quiz */
parent_ids: number[];
created_at: string;
updated_at: string;
/** count of questions */
question_cnt: number;
/** count passings */
passed_count: number;
/** average time of passing */
average_time: number;
/** set true if squiz realize group functionality */
super: boolean;
/** group of new quiz */
group_id: number;
}
export function quizToRawQuiz(quiz: Quiz): RawQuiz {
return {
...quiz,
config: JSON.stringify(quiz.config),
};
}
export function rawQuizToQuiz(rawQuiz: RawQuiz): Quiz {
let config = defaultQuizConfig;
try {
config = JSON.parse(rawQuiz.config);
} catch (error) {
console.warn("Cannot parse quiz config from string, using default config", error);
}
return {
...rawQuiz,
config,
};
}

72
src/model/quizSettings.ts Normal file

@ -0,0 +1,72 @@
export const quizSetupSteps = {
1: { displayStep: 1, text: "Настройка стартовой страницы" },
2: { displayStep: 1, text: "Настройка стартовой страницы" },
3: { displayStep: 1, text: "Настройка стартовой страницы" },
4: { displayStep: 2, text: "Задайте вопросы" },
5: { displayStep: 3, text: "Настройте авторезультаты" },
6: { displayStep: 3, text: "Настройте авторезультаты" },
7: { displayStep: 4, text: "Оценка графа карты вопросов" },
8: { displayStep: 5, text: "Настройте форму контактов" },
9: { displayStep: 6, text: "Установите квиз" },
10: { displayStep: 7, text: "Запустите рекламу" },
} as const;
export const maxQuizSetupSteps = Math.max(...Object.keys(quizSetupSteps).map(parseInt));
export const maxDisplayQuizSetupSteps = Math.max(...Object.values(quizSetupSteps).map(v => v.displayStep));
export type QuizSetupStep = keyof typeof quizSetupSteps;
export interface QuizConfig {
type: "quiz" | "form";
logo: string;
noStartPage: boolean;
startpageType: "standard" | "expanded" | "centered";
startpage: {
description: string;
button: string;
position: string;
background: {
type: string;
desktop: string;
mobile: string;
video: string;
cycle: boolean;
};
};
info: {
phonenumber: string;
clickable: boolean;
orgname: string;
site: string;
law?: string;
};
meta: string;
}
export const defaultQuizConfig: QuizConfig = {
type: "quiz",
logo: "",
noStartPage: false,
startpageType: "standard",
startpage: {
description: "",
button: "",
position: "left",
background: {
type: "none",
desktop: "",
mobile: "",
video: "",
cycle: false,
},
},
info: {
phonenumber: "",
clickable: false,
orgname: "",
site: "",
law: "",
},
meta: "",
};

@ -1,8 +1,8 @@
import { Button, Typography } from "@mui/material";
import { createQuiz } from "@root/quizesV2";
import SectionWrapper from "@ui_kit/SectionWrapper";
import ComplexNavText from "./ComplexNavText";
import { useNavigate } from "react-router";
import { createQuiz } from "@root/quizes/actions";
import { useNavigate } from "react-router-dom";
export default function FirstQuiz() {

@ -1,88 +1,100 @@
import { quizApi } from "@api/quiz";
import { devlog } from "@frontend/kitui";
import {
Typography,
Box,
Button,
SxProps,
Theme,
useTheme,
useMediaQuery,
Box,
Button,
SxProps,
Theme,
Typography,
useMediaQuery,
useTheme,
} from "@mui/material";
import ComplexNavText from "./ComplexNavText";
import QuizCard from "./QuizCard";
import SectionWrapper from "@ui_kit/SectionWrapper";
import { isAxiosError } from "axios";
import { enqueueSnackbar } from "notistack";
import React from "react";
import { quizStore } from "@root/quizes";
import FirstQuiz from "./FirstQuiz";
import { useNavigate } from "react-router-dom";
import { createQuiz } from "@root/quizesV2";
import useSWR from "swr";
import ComplexNavText from "./ComplexNavText";
import FirstQuiz from "./FirstQuiz";
import QuizCard from "./QuizCard";
import { setQuizes, createQuiz } from "@root/quizes/actions";
import { useQuizArray } from "@root/quizes/hooks";
interface Props {
outerContainerSx?: SxProps<Theme>;
children?: React.ReactNode;
outerContainerSx?: SxProps<Theme>;
children?: React.ReactNode;
}
export default function MyQuizzesFull({
outerContainerSx: sx,
children,
outerContainerSx: sx,
children,
}: Props) {
const { listQuizes, updateQuizesList, removeQuiz, createBlank } = quizStore();
const navigate = useNavigate();
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down(500));
useSWR("quizes", () => quizApi.getList(), {
onSuccess: setQuizes,
onError: error => {
const message = isAxiosError<string>(error) ? (error.response?.data ?? "") : "";
return (
<>
{Object.keys(listQuizes).length === 0 ? (
<FirstQuiz />
) : (
<SectionWrapper maxWidth="lg">
<ComplexNavText text1="Кабинет квизов" />
<Box
sx={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
mt: "20px",
mb: "30px",
}}
>
<Typography variant="h4">Мои квизы</Typography>
<Button
variant="contained"
sx={{
padding: isMobile ? "10px" : "10px 47px",
minWidth: "44px",
}}
onClick={() => createQuiz(navigate)}
>
{isMobile ? "+" : "Создать +"}
</Button>
</Box>
<Box
sx={{
py: "10px",
display: "flex",
flexWrap: "wrap",
gap: "40px",
mb: "60px",
}}
>
{Object.values(listQuizes).map(({ id, name }) => (
<QuizCard
key={id}
name={name}
openCount={0}
applicationCount={0}
conversionPercent={0}
onClickDelete={() => {
removeQuiz(id);
}}
onClickEdit={() => navigate(`/setting/${id}`)}
/>
))}
</Box>
{children}
</SectionWrapper>
)}
</>
);
devlog("Error creating quiz", error);
enqueueSnackbar(`Не удалось получить квизы. ${message}`);
},
});
const quizArray = useQuizArray();
const navigate = useNavigate();
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down(500));
return (
<>
{quizArray.length === 0 ? (
<FirstQuiz />
) : (
<SectionWrapper maxWidth="lg">
<ComplexNavText text1="Кабинет квизов" />
<Box
sx={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
mt: "20px",
mb: "30px",
}}
>
<Typography variant="h4">Мои квизы</Typography>
<Button
variant="contained"
sx={{
padding: isMobile ? "10px" : "10px 47px",
minWidth: "44px",
}}
onClick={() => createQuiz(navigate)}
>
{isMobile ? "+" : "Создать +"}
</Button>
</Box>
<Box
sx={{
py: "10px",
display: "flex",
flexWrap: "wrap",
gap: "40px",
mb: "60px",
}}
>
{quizArray.map(quiz => (
<QuizCard
key={quiz.id}
quiz={quiz}
openCount={0}
applicationCount={0}
conversionPercent={0}
/>
))}
</Box>
{children}
</SectionWrapper>
)}
</>
);
}

@ -1,142 +1,147 @@
import {
Box,
Button,
IconButton,
Typography,
useTheme,
useMediaQuery,
} from "@mui/material";
import ChartIcon from "@icons/ChartIcon";
import LinkIcon from "@icons/LinkIcon";
import PencilIcon from "@icons/PencilIcon";
import { Quiz } from "@model/quiz/quiz";
import MoreHorizIcon from "@mui/icons-material/MoreHoriz";
import {
Box,
Button,
IconButton,
Typography,
useMediaQuery,
useTheme,
} from "@mui/material";
import { deleteQuiz } from "@root/quizes/actions";
import { useNavigate } from "react-router-dom";
interface Props {
name: string;
openCount?: number;
applicationCount?: number;
conversionPercent?: number;
onClickDelete?: () => void;
onClickEdit?: () => void;
quiz: Quiz;
openCount?: number;
applicationCount?: number;
conversionPercent?: number;
}
export default function QuizCard({
name,
openCount = 0,
applicationCount = 0,
conversionPercent = 0,
onClickDelete,
onClickEdit,
quiz,
openCount = 0,
applicationCount = 0,
conversionPercent = 0,
}: Props) {
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down(600));
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down(600));
const navigate = useNavigate();
return (
<Box
sx={{
display: "flex",
flexDirection: "column",
backgroundColor: "white",
width: "560px",
height: "280px",
p: "20px",
borderRadius: "12px",
boxSizing: "border-box",
boxShadow: `0px 100px 309px rgba(210, 208, 225, 0.24),
function handleEditClick() {
navigate(`/setting/${quiz.id}`);
}
return (
<Box
sx={{
display: "flex",
flexDirection: "column",
backgroundColor: "white",
width: "560px",
height: "280px",
p: "20px",
borderRadius: "12px",
boxSizing: "border-box",
boxShadow: `0px 100px 309px rgba(210, 208, 225, 0.24),
0px 41.7776px 129.093px rgba(210, 208, 225, 0.172525),
0px 22.3363px 69.0192px rgba(210, 208, 225, 0.143066),
0px 12.5216px 38.6916px rgba(210, 208, 225, 0.12),
0px 6.6501px 20.5488px rgba(210, 208, 225, 0.0969343),
0px 2.76726px 8.55082px rgba(210, 208, 225, 0.0674749)`,
}}
>
<Typography variant="h5">{name}</Typography>
<Box
sx={{
display: "flex",
alignItems: "center",
mt: "10px",
gap: "10px",
}}
>
<LinkIcon bgcolor="#EEE4FC" color={theme.palette.brightPurple.main} />
<Typography color={theme.palette.grey3.main}>
быстрая ссылка ...
</Typography>
</Box>
<Box
sx={{
display: "flex",
mt: "32px",
mr: "22px",
}}
>
<Box sx={{ flex: "1 1 0" }}>
<Typography variant="h5">{openCount}</Typography>
<Typography color={theme.palette.grey3.main}>Открытий</Typography>
</Box>
<Box sx={{ flex: "1 1 0" }}>
<Typography variant="h5">{openCount}</Typography>
<Typography color={theme.palette.grey3.main}>Заявок</Typography>
</Box>
<Box sx={{ flex: "1 1 0" }}>
<Typography variant="h5">{openCount} %</Typography>
<Typography color={theme.palette.grey3.main}>Конверсия</Typography>
</Box>
</Box>
<Box
sx={{
mt: "auto",
display: "flex",
gap: isMobile ? "10px" : "20px",
}}
>
<Button
variant="contained"
sx={{
padding: "10px 39px",
}}
}}
>
Заявки
</Button>
<Button
variant="outlined"
startIcon={<PencilIcon />}
onClick={onClickEdit}
sx={{
padding: isMobile ? "10px" : "10px 20px",
minWidth: "unset",
color: theme.palette.brightPurple.main,
"& .MuiButton-startIcon": {
marginRight: isMobile ? 0 : "4px",
marginLeft: 0,
},
}}
>
{isMobile ? "" : "Редактировать"}
</Button>
<Button
variant="outlined"
startIcon={<ChartIcon />}
sx={{
minWidth: "46px",
padding: "10px 10px",
"& .MuiButton-startIcon": {
mr: 0,
ml: 0,
},
}}
/>
<IconButton
sx={{
color: theme.palette.brightPurple.main,
ml: "auto",
}}
onClick={onClickDelete}
>
<MoreHorizIcon sx={{ transform: "scale(1.75)" }} />
</IconButton>
</Box>
</Box>
);
<Typography variant="h5">{quiz.name}</Typography>
<Box
sx={{
display: "flex",
alignItems: "center",
mt: "10px",
gap: "10px",
}}
>
<LinkIcon bgcolor="#EEE4FC" color={theme.palette.brightPurple.main} />
<Typography color={theme.palette.grey3.main}>
быстрая ссылка ...
</Typography>
</Box>
<Box
sx={{
display: "flex",
mt: "32px",
mr: "22px",
}}
>
<Box sx={{ flex: "1 1 0" }}>
<Typography variant="h5">{openCount}</Typography>
<Typography color={theme.palette.grey3.main}>Открытий</Typography>
</Box>
<Box sx={{ flex: "1 1 0" }}>
<Typography variant="h5">{applicationCount}</Typography>
<Typography color={theme.palette.grey3.main}>Заявок</Typography>
</Box>
<Box sx={{ flex: "1 1 0" }}>
<Typography variant="h5">{conversionPercent} %</Typography>
<Typography color={theme.palette.grey3.main}>Конверсия</Typography>
</Box>
</Box>
<Box
sx={{
mt: "auto",
display: "flex",
gap: isMobile ? "10px" : "20px",
}}
>
<Button
variant="contained"
sx={{
padding: "10px 39px",
}}
>
Заявки
</Button>
<Button
variant="outlined"
startIcon={<PencilIcon />}
onClick={handleEditClick}
sx={{
padding: isMobile ? "10px" : "10px 20px",
minWidth: "unset",
color: theme.palette.brightPurple.main,
"& .MuiButton-startIcon": {
marginRight: isMobile ? 0 : "4px",
marginLeft: 0,
},
}}
>
{isMobile ? "" : "Редактировать"}
</Button>
<Button
variant="outlined"
startIcon={<ChartIcon />}
sx={{
minWidth: "46px",
padding: "10px 10px",
"& .MuiButton-startIcon": {
mr: 0,
ml: 0,
},
}}
/>
<IconButton
sx={{
color: theme.palette.brightPurple.main,
ml: "auto",
}}
onClick={() => deleteQuiz(quiz.id)}
>
<MoreHorizIcon sx={{ transform: "scale(1.75)" }} />
</IconButton>
</Box>
</Box>
);
}

@ -1,270 +1,270 @@
import { quizApi } from "@api/quiz";
import { devlog } from "@frontend/kitui";
import BackArrowIcon from "@icons/BackArrowIcon";
import { Burger } from "@icons/Burger";
import EyeIcon from "@icons/EyeIcon";
import { PenaLogoIcon } from "@icons/PenaLogoIcon";
import {
Box,
Button,
Container,
FormControl,
IconButton,
TextField,
useMediaQuery,
useTheme,
} from "@mui/material";
import { decrementCurrentStep, setQuizes } from "@root/quizes/actions";
import { useCurrentQuiz } from "@root/quizes/hooks";
import { useQuizStore } from "@root/quizes/store";
import CustomAvatar from "@ui_kit/Header/Avatar";
import NavMenuItem from "@ui_kit/Header/NavMenuItem";
import PenaLogo from "@ui_kit/PenaLogo";
import Sidebar from "@ui_kit/Sidebar";
import Stepper from "@ui_kit/Stepper";
import SwitchStepPages from "@ui_kit/switchStepPages";
import React, { useState } from "react";
import PenaLogo from "@ui_kit/PenaLogo";
import {
Box,
Button,
Container,
FormControl,
IconButton,
TextField,
useMediaQuery,
useTheme,
} from "@mui/material";
import { isAxiosError } from "axios";
import { enqueueSnackbar } from "notistack";
import { useState } from "react";
import { Link } from "react-router-dom";
import BackArrowIcon from "@icons/BackArrowIcon";
import NavMenuItem from "@ui_kit/Header/NavMenuItem";
import EyeIcon from "@icons/EyeIcon";
import CustomAvatar from "@ui_kit/Header/Avatar";
import Sidebar from "@ui_kit/Sidebar";
import { quizStore } from "@root/quizes";
import { useParams } from "react-router-dom";
import { Burger } from "@icons/Burger";
import { PenaLogoIcon } from "@icons/PenaLogoIcon";
import useSWR from "swr";
import { SidebarMobile } from "./Sidebar/SidebarMobile";
const DESCRIPTIONS = [
"Настройка стартовой страницы",
"Задайте вопросы",
"Настройте авторезультаты",
"Оценка графа карты вопросов",
"Настройте форму контактов",
"Установите квиз",
"Запустите рекламу",
] as const;
export default function StartPage() {
const { listQuizes, updateQuizesList, removeQuiz, createBlank } = quizStore();
const params = Number(useParams().quizId);
const activeStep = listQuizes[params].step;
const theme = useTheme();
const isTablet = useMediaQuery(theme.breakpoints.down(1000));
const isMobile = useMediaQuery(theme.breakpoints.down(660));
useSWR("quizes", () => quizApi.getList(), {
onSuccess: setQuizes,
onError: error => {
const message = isAxiosError<string>(error) ? (error.response?.data ?? "") : "";
const [mobileSidebar, setMobileSidebar] = useState<boolean>(false);
devlog("Error creating quiz", error);
enqueueSnackbar(`Не удалось получить квизы. ${message}`);
},
});
const theme = useTheme();
const quiz = useCurrentQuiz();
const currentStep = useQuizStore(state => state.currentStep);
const isTablet = useMediaQuery(theme.breakpoints.down(1000));
const isMobile = useMediaQuery(theme.breakpoints.down(660));
const [mobileSidebar, setMobileSidebar] = useState<boolean>(false);
const handleBack = () => {
let result = listQuizes[params].step - 1;
updateQuizesList(params, { step: result ? result : 1 });
};
const quizConfig = quiz?.config;
return (
<>
{/*хедер*/}
<Container
component="nav"
maxWidth={false}
disableGutters
sx={{
px: "16px",
display: "flex",
height: isMobile ? "51px" : "80px",
alignItems: "center",
bgcolor: isMobile ? "#333647" : "white",
borderBottom: "1px solid #E3E3E3",
zIndex: theme.zIndex.drawer + 1,
}}
>
<Link to="/" style={{ display: "flex" }}>
{isMobile ? (
<PenaLogoIcon style={{ fontSize: "39px", color: "white" }} />
) : (
<PenaLogo width={124} />
)}
</Link>
<Box
sx={{
display: isMobile ? "none" : "flex",
alignItems: "center",
ml: "37px",
}}
>
<IconButton sx={{ p: "6px" }} onClick={() => handleBack()}>
<BackArrowIcon />
</IconButton>
<FormControl fullWidth variant="standard">
<TextField
fullWidth
id="project-name"
placeholder="Название проекта окно"
sx={{
width: "270px",
"& .MuiInputBase-root": {
height: "34px",
borderRadius: "8px",
p: 0,
},
}}
inputProps={{
sx: {
height: "20px",
borderRadius: "8px",
fontSize: "16px",
lineHeight: "20px",
p: "7px",
color: "black",
"&::placeholder": {
opacity: 1,
},
},
}}
/>
</FormControl>
</Box>
{isTablet ? (
<Box
sx={{
display: "flex",
ml: "auto",
}}
>
{isMobile ? (
<Burger
onClick={() => setMobileSidebar(!mobileSidebar)}
style={{ fontSize: "30px", color: "white", cursor: "pointer" }}
/>
) : (
<CustomAvatar
return (
<>
{/*хедер*/}
<Container
component="nav"
maxWidth={false}
disableGutters
sx={{
ml: "11px",
backgroundColor: theme.palette.orange.main,
height: "36px",
width: "36px",
px: "16px",
display: "flex",
height: isMobile ? "51px" : "80px",
alignItems: "center",
bgcolor: isMobile ? "#333647" : "white",
borderBottom: "1px solid #E3E3E3",
zIndex: theme.zIndex.drawer + 1,
}}
/>
)}
</Box>
) : (
<>
>
<Link to="/" style={{ display: "flex" }}>
{isMobile ? (
<PenaLogoIcon style={{ fontSize: "39px", color: "white" }} />
) : (
<PenaLogo width={124} />
)}
</Link>
<Box
sx={{
display: isMobile ? "none" : "flex",
alignItems: "center",
ml: "37px",
}}
>
<IconButton sx={{ p: "6px" }} onClick={decrementCurrentStep}>
<BackArrowIcon />
</IconButton>
<FormControl fullWidth variant="standard">
<TextField
fullWidth
id="project-name"
placeholder="Название проекта окно"
sx={{
width: "270px",
"& .MuiInputBase-root": {
height: "34px",
borderRadius: "8px",
p: 0,
},
}}
inputProps={{
sx: {
height: "20px",
borderRadius: "8px",
fontSize: "16px",
lineHeight: "20px",
p: "7px",
color: "black",
"&::placeholder": {
opacity: 1,
},
},
}}
/>
</FormControl>
</Box>
{isTablet ? (
<Box
sx={{
display: "flex",
ml: "auto",
}}
>
{isMobile ? (
<Burger
onClick={() => setMobileSidebar(!mobileSidebar)}
style={{ fontSize: "30px", color: "white", cursor: "pointer" }}
/>
) : (
<CustomAvatar
sx={{
ml: "11px",
backgroundColor: theme.palette.orange.main,
height: "36px",
width: "36px",
}}
/>
)}
</Box>
) : (
<>
<Box
sx={{
display: "flex",
gap: "30px",
overflow: "hidden",
ml: "20px",
}}
>
<NavMenuItem text="Редактировать" isActive />
<NavMenuItem text="Заявки" />
<NavMenuItem text="Аналитика" />
<NavMenuItem text="История" />
<NavMenuItem text="Помощь" />
</Box>
<Box
sx={{
display: "flex",
ml: "auto",
gap: "15px",
}}
>
<Button
variant="outlined"
startIcon={<EyeIcon />}
sx={{
color: theme.palette.brightPurple.main,
fontSize: "14px",
lineHeight: "18px",
height: "34px",
"& .MuiButton-startIcon": {
mr: "3px",
},
}}
>
Предпросмотр
</Button>
<Button
variant="contained"
sx={{
fontSize: "14px",
lineHeight: "18px",
height: "34px",
}}
>
Опубликовать
</Button>
<CustomAvatar
sx={{
ml: "11px",
backgroundColor: theme.palette.orange.main,
height: "36px",
width: "36px",
}}
/>
</Box>
</>
)}
</Container>
<Box
sx={{
display: "flex",
gap: "30px",
overflow: "hidden",
ml: "20px",
}}
sx={{
display: isMobile ? "block" : "flex",
}}
>
<NavMenuItem text="Редактировать" isActive />
<NavMenuItem text="Заявки" />
<NavMenuItem text="Аналитика" />
<NavMenuItem text="История" />
<NavMenuItem text="Помощь" />
{isMobile ? <SidebarMobile open={mobileSidebar} /> : <Sidebar />}
<Box
sx={{
background: theme.palette.background.default,
width: "100%",
padding: isMobile ? "16px" : "25px",
height: "calc(100vh - 80px)",
overflow: "auto",
boxSizing: "border-box",
}}
>
{quizConfig &&
<>
<Stepper activeStep={currentStep} />
<SwitchStepPages
activeStep={currentStep}
quizType={quizConfig.type}
/>
</>
}
</Box>
{isTablet && [1, 2, 3].includes(currentStep) && (
<Box
sx={{
position: "absolute",
left: isMobile ? 0 : "230px",
bottom: 0,
width: isMobile ? "100%" : "calc(100% - 230px)",
padding: "20px 40px",
display: "flex",
justifyContent: "flex-end",
gap: "15px",
background: "#FFF",
}}
>
<Button
variant="outlined"
startIcon={<EyeIcon />}
sx={{
color: theme.palette.brightPurple.main,
fontSize: "14px",
lineHeight: "18px",
height: "34px",
"& .MuiButton-startIcon": {
mr: "3px",
},
}}
>
Предпросмотр
</Button>
<Button
variant="contained"
sx={{
fontSize: "14px",
lineHeight: "18px",
height: "34px",
}}
>
Опубликовать
</Button>
</Box>
)}
</Box>
<Box
sx={{
display: "flex",
ml: "auto",
gap: "15px",
}}
>
<Button
variant="outlined"
startIcon={<EyeIcon />}
sx={{
color: theme.palette.brightPurple.main,
fontSize: "14px",
lineHeight: "18px",
height: "34px",
"& .MuiButton-startIcon": {
mr: "3px",
},
}}
>
Предпросмотр
</Button>
<Button
variant="contained"
sx={{
fontSize: "14px",
lineHeight: "18px",
height: "34px",
}}
>
Опубликовать
</Button>
<CustomAvatar
sx={{
ml: "11px",
backgroundColor: theme.palette.orange.main,
height: "36px",
width: "36px",
}}
/>
</Box>
</>
)}
</Container>
<Box
sx={{
display: isMobile ? "block" : "flex",
}}
>
{isMobile ? <SidebarMobile open={mobileSidebar} /> : <Sidebar />}
<Box
sx={{
background: theme.palette.background.default,
width: "100%",
padding: isMobile ? "16px" : "25px",
height: "calc(100vh - 80px)",
overflow: "auto",
boxSizing: "border-box",
}}
>
<Stepper
activeStep={activeStep}
desc={DESCRIPTIONS[activeStep - 1]}
/>
<SwitchStepPages
activeStep={activeStep}
quizType={listQuizes[params].config.type}
startpage={listQuizes[params].startpage}
createResult={listQuizes[params].createResult}
/>
</Box>
{isTablet && activeStep === 1 && (
<Box
sx={{
position: "absolute",
left: isMobile ? 0 : "230px",
bottom: 0,
width: isMobile ? "100%" : "calc(100% - 230px)",
padding: "20px 40px",
display: "flex",
justifyContent: "flex-end",
gap: "15px",
background: "#FFF",
}}
>
<Button
variant="outlined"
startIcon={<EyeIcon />}
sx={{
color: theme.palette.brightPurple.main,
fontSize: "14px",
lineHeight: "18px",
height: "34px",
"& .MuiButton-startIcon": {
mr: "3px",
},
}}
>
Предпросмотр
</Button>
<Button
variant="contained"
sx={{
fontSize: "14px",
lineHeight: "18px",
height: "34px",
}}
>
Опубликовать
</Button>
</Box>
)}
</Box>
</>
);
</>
);
}

@ -1,71 +1,71 @@
import { Box, Button, useTheme } from "@mui/material";
import { Box, Button } from "@mui/material";
import CreationCard from "@ui_kit/CreationCard";
import { useNavigate } from "react-router-dom";
import quizCreationImage1 from "../../assets/quiz-creation-1.png";
import quizCreationImage2 from "../../assets/quiz-creation-2.png";
import { useParams } from "react-router-dom";
import { quizStore } from "@root/quizes";
import { setQuizType } from "@root/quizes/actions";
import { useCurrentQuiz } from "@root/quizes/hooks";
export default function StepOne() {
const params = Number(useParams().quizId);
const theme = useTheme();
const navigate = useNavigate();
const quiz = useCurrentQuiz();
const config = quiz?.config;
const { listQuizes, updateQuizesList } = quizStore();
return (
<Box
sx={{
overflowX: "scroll",
padding: "0 5px 15px",
"&::-webkit-scrollbar": { width: 0 },
}}
>
<Box
sx={{
minWidth: "720px",
display: "flex",
gap: "20px",
mt: "60px",
}}
>
<Button
variant="text"
data-cy="create-quiz-card"
onClick={() => {
let SPageClone = listQuizes[params].config;
SPageClone.type = "quize";
updateQuizesList(params, { config: SPageClone });
}}
if (!config) return null;
return (
<Box
sx={{
overflowX: "scroll",
padding: "0 5px 15px",
"&::-webkit-scrollbar": { width: 0 },
}}
>
<CreationCard
header="Создание квиз-опроса"
text="У стартовой страницы одна ключевая задача - заинтересовать посетителя пройти квиз. С ней сложно ошибиться, сформулируйте суть предложения и подберите живую фотографию, остальное мы сделаем за вас"
image={quizCreationImage1}
border={
listQuizes[params].config.type === "quize"
? "1px solid #7E2AEA"
: "none"
}
/>
</Button>
<Button
variant="text"
onClick={() => {
let SPageClone = listQuizes[params].config;
SPageClone.type = "form";
updateQuizesList(params, { config: SPageClone });
}}
>
<CreationCard
header="Создание анкеты"
text="У стартовой страницы одна ключевая задача - заинтересовать посетителя пройти квиз. С ней сложно ошибиться, сформулируйте суть предложения и подберите живую фотографию, остальное мы сделаем за вас"
image={quizCreationImage2}
border={
listQuizes[params].config.type === "form"
? "1px solid #7E2AEA"
: "none"
}
/>
</Button>
</Box>
</Box>
);
<Box
sx={{
minWidth: "720px",
display: "flex",
gap: "20px",
mt: "60px",
}}
>
<Button
variant="text"
data-cy="create-quiz-card"
onClick={() => {
setQuizType(quiz.id, "quiz", navigate);
}}
>
<CreationCard
header="Создание квиз-опроса"
text="У стартовой страницы одна ключевая задача - заинтересовать посетителя пройти квиз. С ней сложно ошибиться, сформулируйте суть предложения и подберите живую фотографию, остальное мы сделаем за вас"
image={quizCreationImage1}
border={
config.type === "quiz"
? "1px solid #7E2AEA"
: "none"
}
/>
</Button>
<Button
variant="text"
onClick={() => {
setQuizType(quiz.id,"form", navigate);
}}
>
<CreationCard
header="Создание анкеты"
text="У стартовой страницы одна ключевая задача - заинтересовать посетителя пройти квиз. С ней сложно ошибиться, сформулируйте суть предложения и подберите живую фотографию, остальное мы сделаем за вас"
image={quizCreationImage2}
border={
config.type === "form"
? "1px solid #7E2AEA"
: "none"
}
/>
</Button>
</Box>
</Box>
);
}

@ -1,93 +1,99 @@
import {
Box,
Button,
Typography,
useTheme,
useMediaQuery,
Box,
Button,
Typography,
useMediaQuery,
useTheme,
} from "@mui/material";
import CardWithImage from "./CardWithImage";
import { setQuizStartpageType } from "@root/quizes/actions";
import { useCurrentQuiz } from "@root/quizes/hooks";
import { useNavigate } from "react-router-dom";
import cardImage1 from "../../assets/card-1.png";
import cardImage2 from "../../assets/card-2.png";
import cardImage3 from "../../assets/card-3.png";
import { quizStore } from "@root/quizes";
import { useParams } from "react-router-dom";
import CardWithImage from "./CardWithImage";
export default function Steptwo() {
const params = Number(useParams().quizId);
const { listQuizes, updateQuizesList } = quizStore();
const theme = useTheme();
const isSmallMonitor = useMediaQuery(theme.breakpoints.down(1300));
const navigate = useNavigate();
const theme = useTheme();
const isSmallMonitor = useMediaQuery(theme.breakpoints.down(1300));
const quiz = useCurrentQuiz();
return (
<Box sx={{ mt: "60px" }}>
<Typography variant="h5">Стартовая страница</Typography>
<Box
sx={{
overflowX: "scroll",
paddingBottom: "15px",
"&::-webkit-scrollbar": { width: 0 },
}}
>
<Box
sx={{
minWidth: "950px",
display: "flex",
gap: "20px",
mt: "40px",
padding: isSmallMonitor ? "0 15px 15px" : 0,
}}
>
<Button
variant="text"
data-cy="select-quiz-layout-standard"
onClick={() => {
updateQuizesList(params, { startpage: "standard" });
}}
>
<CardWithImage
image={cardImage1}
text="Standard"
border={
listQuizes[params].startpage === "standard"
? "1px solid #7E2AEA"
: "none"
}
/>
</Button>
<Button
variant="text"
onClick={() => {
updateQuizesList(params, { startpage: "expanded" });
}}
>
<CardWithImage
image={cardImage2}
text="Expanded"
border={
listQuizes[params].startpage === "expanded"
? "1px solid #7E2AEA"
: "none"
}
/>
</Button>
<Button
variant="text"
onClick={() => {
updateQuizesList(params, { startpage: "centered" });
}}
>
<CardWithImage
image={cardImage3}
text="Centered"
border={
listQuizes[params].startpage === "centered"
? "1px solid #7E2AEA"
: "none"
}
/>
</Button>
const config = quiz?.config;
if (!config) return null;
return (
<Box sx={{ mt: "60px" }}>
<Typography variant="h5">Стартовая страница</Typography>
<Box
sx={{
overflowX: "scroll",
paddingBottom: "15px",
"&::-webkit-scrollbar": { width: 0 },
}}
>
<Box
sx={{
minWidth: "950px",
display: "flex",
gap: "20px",
mt: "40px",
padding: isSmallMonitor ? "0 15px 15px" : 0,
}}
>
<Button
variant="text"
data-cy="select-quiz-layout-standard"
onClick={() => {
setQuizStartpageType(quiz.id, "standard", navigate);
}}
>
<CardWithImage
image={cardImage1}
text="Standard"
border={
config.startpageType === "standard"
? "1px solid #7E2AEA"
: "none"
}
/>
</Button>
<Button
variant="text"
onClick={() => {
setQuizStartpageType(quiz.id, "expanded", navigate);
}}
>
<CardWithImage
image={cardImage2}
text="Expanded"
border={
config.startpageType === "expanded"
? "1px solid #7E2AEA"
: "none"
}
/>
</Button>
<Button
variant="text"
onClick={() => {
setQuizStartpageType(quiz.id, "centered", navigate);
}}
>
<CardWithImage
image={cardImage3}
text="Centered"
border={
config.startpageType === "centered"
? "1px solid #7E2AEA"
: "none"
}
/>
</Button>
</Box>
</Box>
</Box>
</Box>
</Box>
);
);
}

@ -80,7 +80,7 @@ export const setQuestionFieldOptimistic = async <T extends keyof Question>(
} catch (error) {
if (isAxiosCanceledError(error)) return;
devlog("Error editing question", { error, question: question, currentUpdatedQuestion });
devlog("Error editing question", { error, question, currentUpdatedQuestion });
enqueueSnackbar("Не удалось сохранить вопрос");
if (!savedOriginalQuestion) {
devlog("Cannot rollback question");

@ -0,0 +1,195 @@
import { quizApi } from "@api/quiz";
import { devlog, getMessageFromFetchError } from "@frontend/kitui";
import { quizToEditQuizRequest } from "@model/quiz/edit";
import { Quiz, RawQuiz, rawQuizToQuiz } from "@model/quiz/quiz";
import { QuizConfig, QuizSetupStep, maxQuizSetupSteps } from "@model/quizSettings";
import { produce } from "immer";
import { enqueueSnackbar } from "notistack";
import { NavigateFunction } from "react-router-dom";
import { QuizStore, useQuizStore } from "./store";
import { isAxiosCanceledError } from "../../utils/isAxiosCanceledError";
export const setQuizes = (quizes: RawQuiz[] | null) => setProducedState(state => {
state.quizById = {};
if (quizes === null) return;
quizes.forEach(quiz => state.quizById[quiz.id] = rawQuizToQuiz(quiz));
}, {
type: "setQuizes",
quizes,
});
export const setQuiz = (quiz: Quiz) => setProducedState(state => {
state.quizById[quiz.id] = quiz;
}, {
type: "setQuiz",
quiz,
});
export const removeQuiz = (quizId: number) => setProducedState(state => {
delete state.quizById[quizId];
}, {
type: "removeQuiz",
quizId,
});
export const setQuizField = <T extends keyof Quiz>(
quizId: number,
field: T,
value: Quiz[T],
) => setProducedState(state => {
const quiz = state.quizById[quizId];
if (!quiz) return;
quiz[field] = value;
}, {
type: "setQuizField",
quizId,
field,
value,
});
export const updateQuiz = (
quizId: number,
updateFn: (quiz: Quiz) => void,
) => setProducedState(state => {
const quiz = state.quizById[quizId];
if (!quiz) return;
updateFn(quiz);
}, {
type: "updateQuiz",
quizId,
updateFn: updateFn.toString(),
});
export const incrementCurrentStep = () => setProducedState(state => {
state.currentStep = Math.min(
maxQuizSetupSteps, state.currentStep + 1
) as QuizSetupStep;
});
export const decrementCurrentStep = () => setProducedState(state => {
state.currentStep = Math.max(
1, state.currentStep - 1
) as QuizSetupStep;
});
export const setCurrentStep = (step: number) => setProducedState(state => {
state.currentStep = Math.max(0, Math.min(maxQuizSetupSteps, step)) as QuizSetupStep;
});
export const createQuiz = async (navigate: NavigateFunction) => {
try {
const quiz = await quizApi.create({
name: "Quiz name",
description: "Quiz description",
});
setQuiz(rawQuizToQuiz(quiz));
navigate(`/setting/${quiz.id}`);
} catch (error) {
devlog("Error creating quiz", error);
const message = getMessageFromFetchError(error) ?? "";
enqueueSnackbar(`Не удалось создать квиз. ${message}`);
}
};
export const deleteQuiz = async (quizId: number) => {
try {
await quizApi.delete(quizId);
removeQuiz(quizId);
} catch (error) {
devlog("Error deleting quiz", error);
const message = getMessageFromFetchError(error) ?? "";
enqueueSnackbar(`Не удалось удалить квиз. ${message}`);
}
};
export const setQuizType = (
quizId: number,
quizType: QuizConfig["type"],
navigate: NavigateFunction,
) => {
updateQuizWithFnOptimistic(
quizId,
quiz => {
quiz.config.type = quizType;
},
navigate,
);
incrementCurrentStep();
};
export const setQuizStartpageType = (
quizId: number,
startpageType: QuizConfig["startpageType"],
navigate: NavigateFunction,
) => {
updateQuizWithFnOptimistic(
quizId,
quiz => {
quiz.config.startpageType = startpageType;
},
navigate,
);
incrementCurrentStep();
};
let savedOriginalQuiz: Quiz | null = null;
let controller: AbortController | null = null;
export const updateQuizWithFnOptimistic = async (
quizId: number,
updateFn: (quiz: Quiz) => void,
navigate: NavigateFunction,
rollbackOnError = true,
) => {
const quiz = useQuizStore.getState().quizById[quizId] ?? null;
if (!quiz) return;
const currentUpdatedQuiz = produce(quiz, updateFn);
controller?.abort();
controller = new AbortController();
savedOriginalQuiz ??= quiz;
setQuiz(currentUpdatedQuiz);
try {
const { updated } = await quizApi.edit(quizToEditQuizRequest(currentUpdatedQuiz), controller.signal);
// await new Promise((resolve, reject) => setTimeout(reject, 2000, new Error("Api rejected")));
setQuizField(quiz.id, "version", updated);
navigate(`/setting/${quizId}`, { replace: true });
controller = null;
savedOriginalQuiz = null;
} catch (error) {
if (isAxiosCanceledError(error)) return;
devlog("Error editing quiz", { error, quiz, currentUpdatedQuiz });
enqueueSnackbar("Не удалось сохранить настройки квиза");
if (rollbackOnError) {
if (!savedOriginalQuiz) {
devlog("Cannot rollback quiz");
throw new Error("Cannot rollback quiz");
}
setQuiz(savedOriginalQuiz);
}
controller = null;
savedOriginalQuiz = null;
}
};
function setProducedState<A extends string | { type: unknown; }>(
recipe: (state: QuizStore) => void,
action?: A,
) {
useQuizStore.setState(state => produce(state, recipe), false, action);
}

@ -0,0 +1,17 @@
import { Quiz } from "@model/quiz/quiz";
import { useParams } from "react-router-dom";
import { useQuizStore } from "./store";
export function useQuizArray(): Quiz[] {
const quizes = useQuizStore(state => state.quizById);
return Object.values(quizes).flatMap(quiz => quiz ? [quiz] : []);
}
export function useCurrentQuiz() {
const quizId = parseInt(useParams().quizId ?? "");
const quiz = useQuizStore(state => state.quizById[quizId]);
return quiz;
}

@ -0,0 +1,25 @@
import { Quiz } from "@model/quiz/quiz";
import { QuizSetupStep } from "@model/quizSettings";
import { create } from "zustand";
import { devtools } from "zustand/middleware";
export type QuizStore = {
quizById: Record<number, Quiz | undefined>;
currentStep: QuizSetupStep;
};
const initialState: QuizStore = {
quizById: {},
currentStep: 1,
};
export const useQuizStore = create<QuizStore>()(
devtools(
() => initialState,
{
name: "QuizStore",
enabled: process.env.NODE_ENV === "development",
}
)
);

@ -1,103 +0,0 @@
import { quizApi } from "@api/quiz";
import { devlog } from "@frontend/kitui";
import { produce } from "immer";
import { BackendQuiz } from "model/quiz/quiz";
import { enqueueSnackbar } from "notistack";
import { NavigateFunction } from "react-router";
import { create } from "zustand";
import { devtools } from "zustand/middleware";
type QuizStore = {
quizes: Record<number, BackendQuiz | undefined>;
};
const initialState: QuizStore = {
quizes: {},
};
export const useQuizStore = create<QuizStore>()(
devtools(
() => initialState,
{
name: "QuizStore",
enabled: process.env.NODE_ENV === "development",
}
)
);
export const setQuizes = (quizes: QuizStore["quizes"]) => useQuizStore.setState({ quizes });
export const setQuiz = (quiz: BackendQuiz) => setProducedState(state => {
state.quizes[quiz.id] = quiz;
}, {
type: "setQuiz",
quiz,
});
export const removeQuiz = (quizId: number) => setProducedState(state => {
delete state.quizes[quizId];
}, {
type: "removeQuiz",
quizId,
});
export const setQuizField = <T extends keyof BackendQuiz>(
quizId: number,
field: T,
value: BackendQuiz[T],
) => setProducedState(state => {
const quiz = state.quizes[quizId];
if (!quiz) return;
quiz[field] = value;
}, {
type: "setQuizField",
quizId,
field,
value,
});
export const updateQuiz = (
quizId: number,
updateFn: (quiz: BackendQuiz) => void,
) => setProducedState(state => {
const quiz = state.quizes[quizId];
if (!quiz) return;
updateFn(quiz);
}, {
type: "updateQuiz",
quizId,
updateFn: updateFn.toString(),
});
export const createQuiz = async (navigate: NavigateFunction) => {
try {
const quiz = await quizApi.create();
setQuiz(quiz);
navigate(`/settings/${quiz.id}`);
} catch (error) {
devlog("Error creating quiz", error);
enqueueSnackbar("Не удалось создать квиз");
}
};
export const deleteQuiz = async (quizId: number) => {
try {
await quizApi.delete(quizId);
removeQuiz(quizId);
} catch (error) {
devlog("Error deleting quiz", error);
enqueueSnackbar("Не удалось удалить квиз");
}
};
function setProducedState<A extends string | { type: unknown; }>(
recipe: (state: QuizStore) => void,
action?: A,
) {
useQuizStore.setState(state => produce(state, recipe), false, action);
}

@ -1,12 +1,12 @@
import { Box, Button, LinearProgress, Paper, Typography } from "@mui/material";
import { questionStore } from "@root/questions";
import {
decrementCurrentQuestionIndex,
incrementCurrentQuestionIndex,
useQuizPreviewStore,
decrementCurrentQuestionIndex,
incrementCurrentQuestionIndex,
useQuizPreviewStore,
} from "@root/quizPreview";
import { DefiniteQuestionType } from "model/questionTypes/shared";
import { FC, useEffect } from "react";
import { AnyQuizQuestion } from "model/questionTypes/shared";
import { useEffect } from "react";
import { useParams } from "react-router-dom";
import ArrowLeft from "../../assets/icons/questionsPage/arrowLeft";
import Date from "./QuizPreviewQuestionTypes/Date";
@ -21,144 +21,140 @@ import Text from "./QuizPreviewQuestionTypes/Text";
import Variant from "./QuizPreviewQuestionTypes/Variant";
import Varimg from "./QuizPreviewQuestionTypes/Varimg";
const QuestionPreviewComponentByType: Record<DefiniteQuestionType, FC<any>> = {
variant: Variant,
images: Images,
varimg: Varimg,
emoji: Emoji,
text: Text,
select: Select,
date: Date,
number: Number,
file: File,
page: Page,
rating: Rating,
};
export default function QuizPreviewLayout() {
const quizId = useParams().quizId ?? 0;
const listQuestions = questionStore((state) => state.listQuestions);
const currentQuizStep = useQuizPreviewStore(
(state) => state.currentQuestionIndex
);
const quizId = useParams().quizId ?? 0;
const listQuestions = questionStore((state) => state.listQuestions);
const currentQuizStep = useQuizPreviewStore(
(state) => state.currentQuestionIndex
);
const quizQuestions = listQuestions[quizId] ?? [];
const nonDeletedQuizQuestions = quizQuestions.filter(
(question) => !question.deleted
);
const maxCurrentQuizStep =
nonDeletedQuizQuestions.length > 0 ? nonDeletedQuizQuestions.length - 1 : 0;
const currentProgress = Math.floor(
(currentQuizStep / maxCurrentQuizStep) * 100
);
const quizQuestions = listQuestions[quizId] ?? [];
const nonDeletedQuizQuestions = quizQuestions.filter(
(question) => !question.deleted
);
const maxCurrentQuizStep =
nonDeletedQuizQuestions.length > 0 ? nonDeletedQuizQuestions.length - 1 : 0;
const currentProgress = Math.floor(
(currentQuizStep / maxCurrentQuizStep) * 100
);
const currentQuestion = nonDeletedQuizQuestions[currentQuizStep];
const QuestionComponent = currentQuestion
? QuestionPreviewComponentByType[
currentQuestion.type as DefiniteQuestionType
]
: null;
const currentQuestion = nonDeletedQuizQuestions[currentQuizStep];
const questionElement = QuestionComponent ? (
<QuestionComponent key={currentQuestion.id} question={currentQuestion} />
) : null;
useEffect(
function resetCurrentQuizStep() {
if (currentQuizStep > maxCurrentQuizStep) {
decrementCurrentQuestionIndex();
}
},
[currentQuizStep, maxCurrentQuizStep]
);
useEffect(
function resetCurrentQuizStep() {
if (currentQuizStep > maxCurrentQuizStep) {
decrementCurrentQuestionIndex();
}
},
[currentQuizStep, maxCurrentQuizStep]
);
return (
<Paper
className="quiz-preview-draghandle"
data-cy="quiz-preview-layout"
sx={{
height: "100%",
display: "flex",
flexDirection: "column",
flexGrow: 1,
borderRadius: "12px",
pointerEvents: "auto",
}}
>
<Box
sx={{
p: "16px",
whiteSpace: "break-spaces",
overflowY: "auto",
flexGrow: 1,
"&::-webkit-scrollbar": { width: 0 },
}}
>
{questionElement}
</Box>
<Box
sx={{
mt: "auto",
p: "16px",
display: "flex",
borderTop: "1px solid #E3E3E3",
alignItems: "center",
}}
>
<Box
sx={{
flexGrow: 1,
display: "flex",
flexDirection: "column",
gap: 1,
}}
return (
<Paper
className="quiz-preview-draghandle"
data-cy="quiz-preview-layout"
sx={{
height: "100%",
display: "flex",
flexDirection: "column",
flexGrow: 1,
borderRadius: "12px",
pointerEvents: "auto",
}}
>
<Typography>
{nonDeletedQuizQuestions.length > 0
? `Вопрос ${currentQuizStep + 1} из ${
nonDeletedQuizQuestions.length
}`
: "Нет вопросов"}
</Typography>
{nonDeletedQuizQuestions.length > 0 && (
<LinearProgress
variant="determinate"
value={currentProgress}
sx={{
"&.MuiLinearProgress-colorPrimary": {
backgroundColor: "fadePurple.main",
},
"& .MuiLinearProgress-barColorPrimary": {
backgroundColor: "brightPurple.main",
},
}}
/>
)}
</Box>
<Box
sx={{
ml: 2,
display: "flex",
gap: 1,
}}
>
<Button
variant="outlined"
onClick={decrementCurrentQuestionIndex}
disabled={currentQuizStep === 0}
sx={{ px: 1, minWidth: 0 }}
>
<ArrowLeft />
</Button>
<Button
variant="contained"
onClick={() => incrementCurrentQuestionIndex(maxCurrentQuizStep)}
disabled={currentQuizStep >= maxCurrentQuizStep}
>
Далее
</Button>
</Box>
</Box>
</Paper>
);
<Box
sx={{
p: "16px",
whiteSpace: "break-spaces",
overflowY: "auto",
flexGrow: 1,
"&::-webkit-scrollbar": { width: 0 },
}}
>
<QuestionPreviewComponent question={currentQuestion} />
</Box>
<Box
sx={{
mt: "auto",
p: "16px",
display: "flex",
borderTop: "1px solid #E3E3E3",
alignItems: "center",
}}
>
<Box
sx={{
flexGrow: 1,
display: "flex",
flexDirection: "column",
gap: 1,
}}
>
<Typography>
{nonDeletedQuizQuestions.length > 0
? `Вопрос ${currentQuizStep + 1} из ${nonDeletedQuizQuestions.length
}`
: "Нет вопросов"}
</Typography>
{nonDeletedQuizQuestions.length > 0 && (
<LinearProgress
variant="determinate"
value={currentProgress}
sx={{
"&.MuiLinearProgress-colorPrimary": {
backgroundColor: "fadePurple.main",
},
"& .MuiLinearProgress-barColorPrimary": {
backgroundColor: "brightPurple.main",
},
}}
/>
)}
</Box>
<Box
sx={{
ml: 2,
display: "flex",
gap: 1,
}}
>
<Button
variant="outlined"
onClick={decrementCurrentQuestionIndex}
disabled={currentQuizStep === 0}
sx={{ px: 1, minWidth: 0 }}
>
<ArrowLeft />
</Button>
<Button
variant="contained"
onClick={() => incrementCurrentQuestionIndex(maxCurrentQuizStep)}
disabled={currentQuizStep >= maxCurrentQuizStep}
>
Далее
</Button>
</Box>
</Box>
</Paper>
);
}
function QuestionPreviewComponent({ question }: {
question: AnyQuizQuestion;
}) {
switch (question.type) {
case "variant": return <Variant question={question} />;
case "images": return <Images question={question} />;
case "varimg": return <Varimg question={question} />;
case "emoji": return <Emoji question={question} />;
case "text": return <Text question={question} />;
case "select": return <Select question={question} />;
case "date": return <Date question={question} />;
case "number": return <Number question={question} />;
case "file": return <File question={question} />;
case "page": return <Page question={question} />;
case "rating": return <Rating question={question} />;
default: throw new Error("Unknown question type");
}
}

@ -1,12 +1,12 @@
import { useState } from "react";
import { useParams } from "react-router-dom";
import {
Container,
Box,
useTheme,
List,
Typography,
IconButton,
Container,
Box,
useTheme,
List,
Typography,
IconButton,
} from "@mui/material";
import { quizStore } from "@root/quizes";
@ -24,156 +24,157 @@ import PuzzlePieceIcon from "@icons/PuzzlePieceIcon";
import GearIcon from "@icons/GearIcon";
import LayoutIcon from "@icons/LayoutIcon";
import MenuItem from "./MenuItem";
import { quizSetupSteps } from "@model/quizSettings";
import { useQuizStore } from "@root/quizes/store";
import { setCurrentStep } from "@root/quizes/actions";
const createQuizMenuItems = [
[LayoutIcon, "Стартовая страница"],
[QuestionIcon, "Вопросы"],
[ChartPieIcon, "Результаты"],
[QuestionsMapIcon, "Карта вопросов"],
[ContactBookIcon, "Форма контактов"],
[FlowArrowIcon, "Установка квиза"],
[MegaphoneIcon, "Запуск рекламы"],
[LayoutIcon, "Стартовая страница"],
[QuestionIcon, "Вопросы"],
[ChartPieIcon, "Результаты"],
[QuestionsMapIcon, "Карта вопросов"],
[ContactBookIcon, "Форма контактов"],
[FlowArrowIcon, "Установка квиза"],
[MegaphoneIcon, "Запуск рекламы"],
] as const;
const quizSettingsMenuItems = [
[TagIcon, "Дополнения"],
[PencilCircleIcon, "Дизайн"],
[PuzzlePieceIcon, "Интеграции"],
[GearIcon, "Настройки"],
[TagIcon, "Дополнения"],
[PencilCircleIcon, "Дизайн"],
[PuzzlePieceIcon, "Интеграции"],
[GearIcon, "Настройки"],
] as const;
export default function Sidebar() {
const theme = useTheme();
const [isMenuCollapsed, setIsMenuCollapsed] = useState(false);
const [progress, setProgress] = useState<number>(1 / 7);
const quizId = Number(useParams().quizId);
const { listQuizes, updateQuizesList } = quizStore();
const theme = useTheme();
const [isMenuCollapsed, setIsMenuCollapsed] = useState(false);
const [progress, setProgress] = useState<number>(1 / 7);
const currentStep = useQuizStore(state => state.currentStep);
const handleMenuCollapseToggle = () => setIsMenuCollapsed((prev) => !prev);
return (
<Box
sx={{
backgroundColor: theme.palette.lightPurple.main,
minWidth: isMenuCollapsed ? "80px" : "230px",
width: isMenuCollapsed ? "80px" : "230px",
height: "calc(100vh - 80px)",
display: "flex",
flexDirection: "column",
py: "19px",
transitionProperty: "width, min-width",
transitionDuration: "200ms",
overflow: "hidden",
whiteSpace: "nowrap",
boxSizing: "border-box",
}}
>
<Box
sx={{
display: "flex",
pl: isMenuCollapsed ? undefined : "16px",
pr: isMenuCollapsed ? undefined : "8px",
mb: isMenuCollapsed ? "5px" : undefined,
alignItems: "center",
justifyContent: isMenuCollapsed ? "center" : undefined,
}}
>
{!isMenuCollapsed && (
<Typography
const handleMenuCollapseToggle = () => setIsMenuCollapsed((prev) => !prev);
return (
<Box
sx={{
fontSize: "14px",
lineHeight: "20px",
fontWeight: 500,
color: theme.palette.grey2.main,
backgroundColor: theme.palette.lightPurple.main,
minWidth: isMenuCollapsed ? "80px" : "230px",
width: isMenuCollapsed ? "80px" : "230px",
height: "calc(100vh - 80px)",
display: "flex",
flexDirection: "column",
py: "19px",
transitionProperty: "width, min-width",
transitionDuration: "200ms",
overflow: "hidden",
whiteSpace: "nowrap",
boxSizing: "border-box",
}}
>
Создание квиза
</Typography>
)}
<IconButton
onClick={handleMenuCollapseToggle}
sx={{ ml: isMenuCollapsed ? undefined : "auto" }}
>
<CollapseMenuIcon
height="16px"
width="16px"
color={theme.palette.grey2.main}
transform={isMenuCollapsed ? "rotate(180deg)" : ""}
/>
</IconButton>
</Box>
<List disablePadding>
{createQuizMenuItems.map((menuItem, index) => {
const Icon = menuItem[0];
return (
<MenuItem
onClick={() => {
updateQuizesList(quizId, { step: index + 1 });
}}
key={menuItem[1]}
text={menuItem[1]}
isCollapsed={isMenuCollapsed}
isActive={listQuizes[quizId].step === index + 1}
icon={
<Icon
color={
listQuizes[quizId].step === index + 1
? theme.palette.brightPurple.main
: isMenuCollapsed
? "white"
: theme.palette.grey2.main
}
height={isMenuCollapsed ? "35px" : "24px"}
width={isMenuCollapsed ? "35px" : "24px"}
/>
}
/>
);
})}
</List>
{!isMenuCollapsed && (
<Typography
sx={{
px: "16px",
mt: "16px",
mb: "11px",
fontSize: "14px",
lineHeight: "20px",
fontWeight: 500,
color: theme.palette.grey2.main,
}}
>
Настройки квиза
</Typography>
)}
<List disablePadding>
{quizSettingsMenuItems.map((menuItem, index) => {
const Icon = menuItem[0];
const totalIndex = index + createQuizMenuItems.length;
const isActive = listQuizes[quizId].step === totalIndex + 1;
return (
<MenuItem
onClick={() => updateQuizesList(quizId, { step: totalIndex + 1 })}
key={menuItem[1]}
text={menuItem[1]}
isActive={isActive}
isCollapsed={isMenuCollapsed}
icon={
<Icon
color={
isActive
? theme.palette.brightPurple.main
: isMenuCollapsed
? "white"
: theme.palette.grey2.main
}
height={isMenuCollapsed ? "35px" : "24px"}
width={isMenuCollapsed ? "35px" : "24px"}
/>
}
/>
);
})}
</List>
</Box>
);
<Box
sx={{
display: "flex",
pl: isMenuCollapsed ? undefined : "16px",
pr: isMenuCollapsed ? undefined : "8px",
mb: isMenuCollapsed ? "5px" : undefined,
alignItems: "center",
justifyContent: isMenuCollapsed ? "center" : undefined,
}}
>
{!isMenuCollapsed && (
<Typography
sx={{
fontSize: "14px",
lineHeight: "20px",
fontWeight: 500,
color: theme.palette.grey2.main,
}}
>
Создание квиза
</Typography>
)}
<IconButton
onClick={handleMenuCollapseToggle}
sx={{ ml: isMenuCollapsed ? undefined : "auto" }}
>
<CollapseMenuIcon
height="16px"
width="16px"
color={theme.palette.grey2.main}
transform={isMenuCollapsed ? "rotate(180deg)" : ""}
/>
</IconButton>
</Box>
<List disablePadding>
{createQuizMenuItems.map((menuItem, index) => {
const Icon = menuItem[0];
return (
<MenuItem
onClick={() => setCurrentStep(index + 1)}
key={menuItem[1]}
text={menuItem[1]}
isCollapsed={isMenuCollapsed}
isActive={quizSetupSteps[currentStep].displayStep === index + 1}
icon={
<Icon
color={
quizSetupSteps[currentStep].displayStep === index + 1
? theme.palette.brightPurple.main
: isMenuCollapsed
? "white"
: theme.palette.grey2.main
}
height={isMenuCollapsed ? "35px" : "24px"}
width={isMenuCollapsed ? "35px" : "24px"}
/>
}
/>
);
})}
</List>
{!isMenuCollapsed && (
<Typography
sx={{
px: "16px",
mt: "16px",
mb: "11px",
fontSize: "14px",
lineHeight: "20px",
fontWeight: 500,
color: theme.palette.grey2.main,
}}
>
Настройки квиза
</Typography>
)}
{/* <List disablePadding> // TODO
{quizSettingsMenuItems.map((menuItem, index) => {
const Icon = menuItem[0];
const totalIndex = index + createQuizMenuItems.length;
const isActive = listQuizes[quizId].step === totalIndex + 1;
return (
<MenuItem
onClick={() => updateQuizesList(quizId, { step: totalIndex + 1 })}
key={menuItem[1]}
text={menuItem[1]}
isActive={isActive}
isCollapsed={isMenuCollapsed}
icon={
<Icon
color={
isActive
? theme.palette.brightPurple.main
: isMenuCollapsed
? "white"
: theme.palette.grey2.main
}
height={isMenuCollapsed ? "35px" : "24px"}
width={isMenuCollapsed ? "35px" : "24px"}
/>
}
/>
);
})}
</List> */}
</Box>
);
}

@ -1,16 +1,13 @@
import * as React from "react";
import MobileStepper from "@mui/material/MobileStepper";
import { Box, Typography, useMediaQuery, useTheme } from "@mui/material";
import MobileStepper from "@mui/material/MobileStepper";
import { QuizSetupStep, maxDisplayQuizSetupSteps, quizSetupSteps } from "@model/quizSettings";
interface Props {
desc?: string;
activeStep?: number;
steps?: number;
activeStep: QuizSetupStep;
}
export default function ProgressMobileStepper({
desc,
activeStep = 1,
steps = 8,
activeStep,
}: Props) {
const theme = useTheme();
const isTablet = useMediaQuery(theme.breakpoints.down(1000));
@ -31,9 +28,9 @@ export default function ProgressMobileStepper({
>
<MobileStepper
variant="progress"
steps={steps}
steps={maxDisplayQuizSetupSteps}
position="static"
activeStep={activeStep}
activeStep={activeStep - 1}
sx={{
width: "100%",
flexGrow: 1,
@ -55,9 +52,9 @@ export default function ProgressMobileStepper({
sx={{ fontWeight: 400, fontSize: "12px", lineHeight: "14.22px" }}
>
{" "}
Шаг {activeStep} из {steps - 1}
Шаг {quizSetupSteps[activeStep].displayStep} из {maxDisplayQuizSetupSteps}
</Typography>
<Typography>{desc}</Typography>
<Typography>{quizSetupSteps[activeStep].text}</Typography>
</Box>
</Box>
);

@ -1,49 +1,37 @@
import * as React from "react";
import StepOne from "../pages/startPage/stepOne";
import Steptwo from "../pages/startPage/steptwo";
import StartPageSettings from "../pages/startPage/StartPageSettings";
import QuestionsPage from "../pages/Questions/QuestionsPage";
import FormQuestionsPage from "../pages/Questions/Form/FormQuestionsPage";
import { QuizSetupStep } from "@model/quizSettings";
import { notReachable } from "../utils/notReachable";
import ContactFormPage from "../pages/ContactFormPage/ContactFormPage";
import InstallQuiz from "../pages/InstallQuiz/InstallQuiz";
import FormQuestionsPage from "../pages/Questions/Form/FormQuestionsPage";
import QuestionsPage from "../pages/Questions/QuestionsPage";
import { QuestionsMap } from "../pages/QuestionsMap";
import { Result } from "../pages/Result/Result";
import { Setting } from "../pages/Result/Setting";
import { QuestionsMap } from "../pages/QuestionsMap";
import StartPageSettings from "../pages/startPage/StartPageSettings";
import StepOne from "../pages/startPage/stepOne";
import Steptwo from "../pages/startPage/steptwo";
interface Props {
activeStep: number;
quizType: string;
startpage: string;
createResult: boolean;
activeStep: QuizSetupStep;
quizType: string;
}
export default function SwitchStepPages({
activeStep = 1,
quizType,
startpage,
createResult,
activeStep = 1,
quizType,
}: Props) {
switch (activeStep) {
case 1:
if (!quizType) return <StepOne />;
if (!startpage) return <Steptwo />;
return <StartPageSettings />;
case 2:
if (quizType === "form") return <FormQuestionsPage />;
return <QuestionsPage />;
case 3:
if (!createResult) return <Result />;
return <Setting />;
case 4:
return <QuestionsMap />;
case 5:
return <ContactFormPage />;
case 6:
return <InstallQuiz />;
case 7:
return <>Реклама</>;
default:
return <></>;
}
switch (activeStep) {
case 1: return <StepOne />;
case 2: return <Steptwo />;
case 3: return <StartPageSettings />;
case 4: return quizType === "form" ? <QuestionsPage /> : <FormQuestionsPage />;
case 5: return <Result />;
case 6: return <Setting />;
case 7: return <QuestionsMap />;
case 8: return <ContactFormPage />;
case 9: return <InstallQuiz />;
case 10: return <>Реклама</>;
default: return notReachable(activeStep);
}
}

@ -0,0 +1,3 @@
export function notReachable(_: never): never {
throw new Error(`Shouldn't reach here: ${_}`);
}

@ -13,6 +13,9 @@
],
"@api/*": [
"./api/*"
],
"@model/*": [
"./model/*"
]
}
}

652
yarn.lock

File diff suppressed because it is too large Load Diff