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,6 +21,7 @@ const localeText = ruRU.components.MuiLocalizationProvider.defaultProps.localeTe
const root = createRoot(document.getElementById("root")!);
root.render(
<SWRConfig value={{ revalidateOnFocus: false, shouldRetryOnError: false }}>
<DndProvider backend={HTML5Backend}>
<LocalizationProvider dateAdapter={AdapterDayjs} adapterLocale="ru" localeText={localeText}>
<ThemeProvider theme={lightTheme}>
@ -30,4 +32,5 @@ root.render(
</ThemeProvider>
</LocalizationProvider>
</DndProvider>
</SWRConfig>
);

@ -1,18 +1,34 @@
export interface CreateQuizRequest {
/** 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: 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,20 +1,27 @@
import { quizApi } from "@api/quiz";
import { devlog } from "@frontend/kitui";
import {
Typography,
Box,
Button,
SxProps,
Theme,
useTheme,
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;
@ -24,14 +31,23 @@ export default function MyQuizzesFull({
outerContainerSx: sx,
children,
}: Props) {
const { listQuizes, updateQuizesList, removeQuiz, createBlank } = quizStore();
useSWR("quizes", () => quizApi.getList(), {
onSuccess: setQuizes,
onError: error => {
const message = isAxiosError<string>(error) ? (error.response?.data ?? "") : "";
devlog("Error creating quiz", error);
enqueueSnackbar(`Не удалось получить квизы. ${message}`);
},
});
const quizArray = useQuizArray();
const navigate = useNavigate();
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down(500));
return (
<>
{Object.keys(listQuizes).length === 0 ? (
{quizArray.length === 0 ? (
<FirstQuiz />
) : (
<SectionWrapper maxWidth="lg">
@ -66,17 +82,13 @@ export default function MyQuizzesFull({
mb: "60px",
}}
>
{Object.values(listQuizes).map(({ id, name }) => (
{quizArray.map(quiz => (
<QuizCard
key={id}
name={name}
key={quiz.id}
quiz={quiz}
openCount={0}
applicationCount={0}
conversionPercent={0}
onClickDelete={() => {
removeQuiz(id);
}}
onClickEdit={() => navigate(`/setting/${id}`)}
/>
))}
</Box>

@ -1,35 +1,40 @@
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,
useTheme,
useMediaQuery,
useTheme,
} from "@mui/material";
import ChartIcon from "@icons/ChartIcon";
import LinkIcon from "@icons/LinkIcon";
import PencilIcon from "@icons/PencilIcon";
import MoreHorizIcon from "@mui/icons-material/MoreHoriz";
import { deleteQuiz } from "@root/quizes/actions";
import { useNavigate } from "react-router-dom";
interface Props {
name: string;
quiz: Quiz;
openCount?: number;
applicationCount?: number;
conversionPercent?: number;
onClickDelete?: () => void;
onClickEdit?: () => void;
}
export default function QuizCard({
name,
quiz,
openCount = 0,
applicationCount = 0,
conversionPercent = 0,
onClickDelete,
onClickEdit,
}: Props) {
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down(600));
const navigate = useNavigate();
function handleEditClick() {
navigate(`/setting/${quiz.id}`);
}
return (
<Box
@ -50,7 +55,7 @@ export default function QuizCard({
0px 2.76726px 8.55082px rgba(210, 208, 225, 0.0674749)`,
}}
>
<Typography variant="h5">{name}</Typography>
<Typography variant="h5">{quiz.name}</Typography>
<Box
sx={{
display: "flex",
@ -76,11 +81,11 @@ export default function QuizCard({
<Typography color={theme.palette.grey3.main}>Открытий</Typography>
</Box>
<Box sx={{ flex: "1 1 0" }}>
<Typography variant="h5">{openCount}</Typography>
<Typography variant="h5">{applicationCount}</Typography>
<Typography color={theme.palette.grey3.main}>Заявок</Typography>
</Box>
<Box sx={{ flex: "1 1 0" }}>
<Typography variant="h5">{openCount} %</Typography>
<Typography variant="h5">{conversionPercent} %</Typography>
<Typography color={theme.palette.grey3.main}>Конверсия</Typography>
</Box>
</Box>
@ -102,7 +107,7 @@ export default function QuizCard({
<Button
variant="outlined"
startIcon={<PencilIcon />}
onClick={onClickEdit}
onClick={handleEditClick}
sx={{
padding: isMobile ? "10px" : "10px 20px",
minWidth: "unset",
@ -132,7 +137,7 @@ export default function QuizCard({
color: theme.palette.brightPurple.main,
ml: "auto",
}}
onClick={onClickDelete}
onClick={() => deleteQuiz(quiz.id)}
>
<MoreHorizIcon sx={{ transform: "scale(1.75)" }} />
</IconButton>

@ -1,7 +1,9 @@
import Stepper from "@ui_kit/Stepper";
import SwitchStepPages from "@ui_kit/switchStepPages";
import React, { useState } from "react";
import PenaLogo from "@ui_kit/PenaLogo";
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,
@ -12,42 +14,41 @@ import {
useMediaQuery,
useTheme,
} from "@mui/material";
import { Link } from "react-router-dom";
import BackArrowIcon from "@icons/BackArrowIcon";
import NavMenuItem from "@ui_kit/Header/NavMenuItem";
import EyeIcon from "@icons/EyeIcon";
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 { quizStore } from "@root/quizes";
import { useParams } from "react-router-dom";
import { Burger } from "@icons/Burger";
import { PenaLogoIcon } from "@icons/PenaLogoIcon";
import Stepper from "@ui_kit/Stepper";
import SwitchStepPages from "@ui_kit/switchStepPages";
import { isAxiosError } from "axios";
import { enqueueSnackbar } from "notistack";
import { useState } from "react";
import { Link } from "react-router-dom";
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;
useSWR("quizes", () => quizApi.getList(), {
onSuccess: setQuizes,
onError: error => {
const message = isAxiosError<string>(error) ? (error.response?.data ?? "") : "";
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 (
<>
@ -80,7 +81,7 @@ export default function StartPage() {
ml: "37px",
}}
>
<IconButton sx={{ p: "6px" }} onClick={() => handleBack()}>
<IconButton sx={{ p: "6px" }} onClick={decrementCurrentStep}>
<BackArrowIcon />
</IconButton>
<FormControl fullWidth variant="standard">
@ -212,18 +213,17 @@ export default function StartPage() {
boxSizing: "border-box",
}}
>
<Stepper
activeStep={activeStep}
desc={DESCRIPTIONS[activeStep - 1]}
/>
{quizConfig &&
<>
<Stepper activeStep={currentStep} />
<SwitchStepPages
activeStep={activeStep}
quizType={listQuizes[params].config.type}
startpage={listQuizes[params].startpage}
createResult={listQuizes[params].createResult}
activeStep={currentStep}
quizType={quizConfig.type}
/>
</>
}
</Box>
{isTablet && activeStep === 1 && (
{isTablet && [1, 2, 3].includes(currentStep) && (
<Box
sx={{
position: "absolute",

@ -1,15 +1,19 @@
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;
if (!config) return null;
const { listQuizes, updateQuizesList } = quizStore();
return (
<Box
sx={{
@ -30,9 +34,7 @@ export default function StepOne() {
variant="text"
data-cy="create-quiz-card"
onClick={() => {
let SPageClone = listQuizes[params].config;
SPageClone.type = "quize";
updateQuizesList(params, { config: SPageClone });
setQuizType(quiz.id, "quiz", navigate);
}}
>
<CreationCard
@ -40,7 +42,7 @@ export default function StepOne() {
text="У стартовой страницы одна ключевая задача - заинтересовать посетителя пройти квиз. С ней сложно ошибиться, сформулируйте суть предложения и подберите живую фотографию, остальное мы сделаем за вас"
image={quizCreationImage1}
border={
listQuizes[params].config.type === "quize"
config.type === "quiz"
? "1px solid #7E2AEA"
: "none"
}
@ -49,9 +51,7 @@ export default function StepOne() {
<Button
variant="text"
onClick={() => {
let SPageClone = listQuizes[params].config;
SPageClone.type = "form";
updateQuizesList(params, { config: SPageClone });
setQuizType(quiz.id,"form", navigate);
}}
>
<CreationCard
@ -59,7 +59,7 @@ export default function StepOne() {
text="У стартовой страницы одна ключевая задача - заинтересовать посетителя пройти квиз. С ней сложно ошибиться, сформулируйте суть предложения и подберите живую фотографию, остальное мы сделаем за вас"
image={quizCreationImage2}
border={
listQuizes[params].config.type === "form"
config.type === "form"
? "1px solid #7E2AEA"
: "none"
}

@ -2,21 +2,27 @@ import {
Box,
Button,
Typography,
useTheme,
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 navigate = useNavigate();
const theme = useTheme();
const isSmallMonitor = useMediaQuery(theme.breakpoints.down(1300));
const quiz = useCurrentQuiz();
const config = quiz?.config;
if (!config) return null;
return (
<Box sx={{ mt: "60px" }}>
@ -41,14 +47,14 @@ export default function Steptwo() {
variant="text"
data-cy="select-quiz-layout-standard"
onClick={() => {
updateQuizesList(params, { startpage: "standard" });
setQuizStartpageType(quiz.id, "standard", navigate);
}}
>
<CardWithImage
image={cardImage1}
text="Standard"
border={
listQuizes[params].startpage === "standard"
config.startpageType === "standard"
? "1px solid #7E2AEA"
: "none"
}
@ -57,14 +63,14 @@ export default function Steptwo() {
<Button
variant="text"
onClick={() => {
updateQuizesList(params, { startpage: "expanded" });
setQuizStartpageType(quiz.id, "expanded", navigate);
}}
>
<CardWithImage
image={cardImage2}
text="Expanded"
border={
listQuizes[params].startpage === "expanded"
config.startpageType === "expanded"
? "1px solid #7E2AEA"
: "none"
}
@ -73,14 +79,14 @@ export default function Steptwo() {
<Button
variant="text"
onClick={() => {
updateQuizesList(params, { startpage: "centered" });
setQuizStartpageType(quiz.id, "centered", navigate);
}}
>
<CardWithImage
image={cardImage3}
text="Centered"
border={
listQuizes[params].startpage === "centered"
config.startpageType === "centered"
? "1px solid #7E2AEA"
: "none"
}

@ -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);
}

@ -5,8 +5,8 @@ import {
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,19 +21,6 @@ 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;
@ -53,15 +40,6 @@ export default function QuizPreviewLayout() {
);
const currentQuestion = nonDeletedQuizQuestions[currentQuizStep];
const QuestionComponent = currentQuestion
? QuestionPreviewComponentByType[
currentQuestion.type as DefiniteQuestionType
]
: null;
const questionElement = QuestionComponent ? (
<QuestionComponent key={currentQuestion.id} question={currentQuestion} />
) : null;
useEffect(
function resetCurrentQuizStep() {
@ -94,7 +72,7 @@ export default function QuizPreviewLayout() {
"&::-webkit-scrollbar": { width: 0 },
}}
>
{questionElement}
<QuestionPreviewComponent question={currentQuestion} />
</Box>
<Box
sx={{
@ -115,8 +93,7 @@ export default function QuizPreviewLayout() {
>
<Typography>
{nonDeletedQuizQuestions.length > 0
? `Вопрос ${currentQuizStep + 1} из ${
nonDeletedQuizQuestions.length
? `Вопрос ${currentQuizStep + 1} из ${nonDeletedQuizQuestions.length
}`
: "Нет вопросов"}
</Typography>
@ -162,3 +139,22 @@ export default function QuizPreviewLayout() {
</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");
}
}

@ -24,6 +24,9 @@ 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, "Стартовая страница"],
@ -46,10 +49,10 @@ 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 currentStep = useQuizStore(state => state.currentStep);
const handleMenuCollapseToggle = () => setIsMenuCollapsed((prev) => !prev);
return (
<Box
sx={{
@ -106,17 +109,15 @@ export default function Sidebar() {
const Icon = menuItem[0];
return (
<MenuItem
onClick={() => {
updateQuizesList(quizId, { step: index + 1 });
}}
onClick={() => setCurrentStep(index + 1)}
key={menuItem[1]}
text={menuItem[1]}
isCollapsed={isMenuCollapsed}
isActive={listQuizes[quizId].step === index + 1}
isActive={quizSetupSteps[currentStep].displayStep === index + 1}
icon={
<Icon
color={
listQuizes[quizId].step === index + 1
quizSetupSteps[currentStep].displayStep === index + 1
? theme.palette.brightPurple.main
: isMenuCollapsed
? "white"
@ -145,7 +146,7 @@ export default function Sidebar() {
Настройки квиза
</Typography>
)}
<List disablePadding>
{/* <List disablePadding> // TODO
{quizSettingsMenuItems.map((menuItem, index) => {
const Icon = menuItem[0];
const totalIndex = index + createQuizMenuItems.length;
@ -173,7 +174,7 @@ export default function Sidebar() {
/>
);
})}
</List>
</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;
activeStep: QuizSetupStep;
quizType: string;
startpage: string;
createResult: boolean;
}
export default function SwitchStepPages({
activeStep = 1,
quizType,
startpage,
createResult,
}: 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 <></>;
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