feat: view

This commit is contained in:
IlyaDoronin 2023-11-30 20:39:57 +03:00
parent 960ee026bd
commit 305b8707ce
36 changed files with 1409 additions and 162 deletions

@ -33,6 +33,7 @@
"react": "^18.2.0",
"react-beautiful-dnd": "^13.1.1",
"react-cytoscapejs": "^2.0.0",
"react-datepicker": "^4.24.0",
"react-dnd": "^16.0.1",
"react-dnd-html5-backend": "^16.0.1",
"react-dom": "^18.2.0",
@ -79,6 +80,7 @@
"@emoji-mart/react": "^1.1.1",
"@types/react-beautiful-dnd": "^13.1.4",
"@types/react-cytoscapejs": "^1.2.4",
"@types/react-datepicker": "^4.19.3",
"craco-alias": "^3.0.1",
"cypress": "^13.4.0"
}

@ -4,6 +4,7 @@ import dayjs from "dayjs";
import "dayjs/locale/ru";
import SigninDialog from "./pages/auth/Signin";
import SignupDialog from "./pages/auth/Signup";
import { ViewPage } from "./pages/ViewPublicationPage";
import { BrowserRouter, Route, Routes } from "react-router-dom";
import "./index.css";
import ContactFormPage from "./pages/ContactFormPage/ContactFormPage";
@ -61,6 +62,7 @@ export default function App() {
<Route path="/" element={<Landing />} />
<Route path="/signin" element={<SigninDialog />} />
<Route path="/signup" element={<SignupDialog />} />
<Route path="view/:quizId" element={<ViewPage />} />
</Routes>
</BrowserRouter>
</>

@ -1,19 +1,23 @@
import { Box } from "@mui/material";
import type { SxProps } from "@mui/material";
interface Props {
color: string;
width?: string;
width?: number;
sx?: SxProps;
}
export default function StarIconMini({ color, width = "30px" }: Props) {
export default function StarIconMini({ color, width = 30, sx }: Props) {
return (
<Box
sx={{
height: "30px",
width: width,
width: width + "px",
display: "flex",
alignItems: "center",
justifyContent: "center",
...sx,
}}
>
<svg width="28" height="27" viewBox="0 0 28 27" fill="none" xmlns="http://www.w3.org/2000/svg">

@ -17,15 +17,8 @@ export const QUIZ_QUESTION_BASE: Omit<QuizQuestionBase, "id" | "backendId"> = {
video: "",
},
rule: {
or: true,
show: true,
title: "",
reqs: [
{
id: "",
vars: [],
},
],
default: "",
main: [],
},
back: "",
originalBack: "",

@ -1,7 +1,7 @@
import type {
QuizQuestionBase,
QuestionHint,
QuestionBranchingRule,
PreviewRule,
} from "./shared";
export interface QuizQuestionDate extends QuizQuestionBase {
@ -16,7 +16,7 @@ export interface QuizQuestionDate extends QuizQuestionBase {
dateRange: boolean;
time: boolean;
hint: QuestionHint;
rule: QuestionBranchingRule;
rule: PreviewRule;
back: string;
originalBack: string;
autofill: boolean;

@ -2,7 +2,7 @@ import type {
QuizQuestionBase,
QuestionVariant,
QuestionHint,
QuestionBranchingRule,
PreviewRule,
} from "./shared";
export interface QuizQuestionEmoji extends QuizQuestionBase {
@ -20,7 +20,7 @@ export interface QuizQuestionEmoji extends QuizQuestionBase {
required: boolean;
variants: QuestionVariant[];
hint: QuestionHint;
rule: QuestionBranchingRule;
rule: PreviewRule;
back: string;
originalBack: string;
autofill: boolean;

@ -1,7 +1,7 @@
import type {
QuizQuestionBase,
QuestionHint,
QuestionBranchingRule,
PreviewRule,
} from "./shared";
export const UPLOAD_FILE_TYPES_MAP = {
@ -27,7 +27,7 @@ export interface QuizQuestionFile extends QuizQuestionBase {
autofill: boolean;
type: UploadFileType;
hint: QuestionHint;
rule: QuestionBranchingRule;
rule: PreviewRule;
back: string;
originalBack: string;
};

@ -1,8 +1,8 @@
import type {
QuestionBranchingRule,
QuestionHint,
QuestionVariant,
QuizQuestionBase
QuizQuestionBase,
PreviewRule,
} from "./shared";
export interface QuizQuestionImages extends QuizQuestionBase {
@ -27,7 +27,7 @@ export interface QuizQuestionImages extends QuizQuestionBase {
/** Варианты (картинки) */
variants: QuestionVariant[];
hint: QuestionHint;
rule: QuestionBranchingRule;
rule: PreviewRule;
back: string;
originalBack: string;
autofill: boolean;

@ -1,7 +1,7 @@
import type {
QuizQuestionBase,
QuestionHint,
QuestionBranchingRule,
PreviewRule,
} from "./shared";
export interface QuizQuestionNumber extends QuizQuestionBase {
@ -25,7 +25,7 @@ export interface QuizQuestionNumber extends QuizQuestionBase {
/** Чекбокс "Выбор диапазона (два ползунка)" */
chooseRange: boolean;
hint: QuestionHint;
rule: QuestionBranchingRule;
rule: PreviewRule;
back: string;
originalBack: string;
autofill: boolean;

@ -1,7 +1,7 @@
import type {
QuizQuestionBase,
QuestionHint,
QuestionBranchingRule,
PreviewRule,
} from "./shared";
export interface QuizQuestionPage extends QuizQuestionBase {
@ -16,7 +16,7 @@ export interface QuizQuestionPage extends QuizQuestionBase {
originalPicture: string;
video: string;
hint: QuestionHint;
rule: QuestionBranchingRule;
rule: PreviewRule;
back: string;
originalBack: string;
autofill: boolean;

@ -1,7 +1,7 @@
import type {
QuizQuestionBase,
QuestionHint,
QuestionBranchingRule,
PreviewRule,
} from "./shared";
export interface QuizQuestionRating extends QuizQuestionBase {
@ -18,7 +18,7 @@ export interface QuizQuestionRating extends QuizQuestionBase {
/** Форма иконки */
form: string;
hint: QuestionHint;
rule: QuestionBranchingRule;
rule: PreviewRule;
back: string;
originalBack: string;
autofill: boolean;

@ -2,7 +2,7 @@ import type {
QuizQuestionBase,
QuestionVariant,
QuestionHint,
QuestionBranchingRule,
PreviewRule,
} from "./shared";
export interface QuizQuestionSelect extends QuizQuestionBase {
@ -19,7 +19,7 @@ export interface QuizQuestionSelect extends QuizQuestionBase {
/** Поле "Текст в выпадающем списке" */
default: string;
variants: QuestionVariant[];
rule: QuestionBranchingRule;
rule: PreviewRule;
hint: QuestionHint;
back: string;
originalBack: string;

@ -12,17 +12,24 @@ import type { QuizQuestionVariant } from "./variant";
import type { QuizQuestionVarImg } from "./varimg";
import { nanoid } from "nanoid";
export type Rule = {
/* question id */
question: string;
/* Ответы на вопросы. Для вариантов выбора - конкретные айдишники ответов, для полей ввода текста - текст по полному совпадению, для ввода файла - просто факт того что файл ввели, т.е. boolean */
answers: (number | string | boolean)[];
};
export interface QuestionBranchingRule {
/** Радиокнопка "Все условия обязательны" */
export type PreviewRuleInfo = {
/* Id следующего вопроса */
next: string;
/* Радиокнопка "Все условия обязательны" */
or: boolean;
show: boolean;
title: string;
reqs: {
id: string;
/** Список выбранных вариантов */
vars: number[];
}[];
rules: Rule[];
};
export interface PreviewRule {
default: string;
main: PreviewRuleInfo[];
}
export interface QuestionHint {
@ -60,7 +67,7 @@ export interface QuizQuestionBase {
deleteTimeoutId: number;
content: {
hint: QuestionHint;
rule: QuestionBranchingRule;
rule: PreviewRule;
back: string;
originalBack: string;
autofill: boolean;
@ -91,11 +98,13 @@ export type AnyTypedQuizQuestion =
| QuizQuestionRating;
type FilterQuestionsWithVariants<T> = T extends {
content: { variants: QuestionVariant[]; };
} ? T : never;
export type QuizQuestionsWithVariants = FilterQuestionsWithVariants<AnyTypedQuizQuestion>;
content: { variants: QuestionVariant[] };
}
? T
: never;
export type QuizQuestionsWithVariants =
FilterQuestionsWithVariants<AnyTypedQuizQuestion>;
export const createQuestionVariant: () => QuestionVariant = () => ({
id: nanoid(),

@ -1,7 +1,7 @@
import type {
QuizQuestionBase,
QuestionHint,
QuestionBranchingRule,
PreviewRule,
} from "./shared";
export interface QuizQuestionText extends QuizQuestionBase {
@ -18,7 +18,7 @@ export interface QuizQuestionText extends QuizQuestionBase {
autofill: boolean;
answerType: "single" | "multi";
hint: QuestionHint;
rule: QuestionBranchingRule;
rule: PreviewRule;
back: string;
originalBack: string;
onlyNumbers: boolean;

@ -2,7 +2,7 @@ import type {
QuizQuestionBase,
QuestionVariant,
QuestionHint,
QuestionBranchingRule,
PreviewRule,
} from "./shared";
export interface QuizQuestionVariant extends QuizQuestionBase {
@ -23,7 +23,7 @@ export interface QuizQuestionVariant extends QuizQuestionBase {
/** Варианты ответов */
variants: QuestionVariant[];
hint: QuestionHint;
rule: QuestionBranchingRule;
rule: PreviewRule;
back: string;
originalBack: string;
autofill: boolean;

@ -1,8 +1,8 @@
import type {
QuestionBranchingRule,
QuestionHint,
QuestionVariant,
QuizQuestionBase
QuizQuestionBase,
PreviewRule,
} from "./shared";
export interface QuizQuestionVarImg extends QuizQuestionBase {
@ -18,7 +18,7 @@ export interface QuizQuestionVarImg extends QuizQuestionBase {
required: boolean;
variants: QuestionVariant[];
hint: QuestionHint;
rule: QuestionBranchingRule;
rule: PreviewRule;
back: string;
originalBack: string;
autofill: boolean;

@ -73,7 +73,7 @@ export default function RatingOptions({ question }: Props) {
const buttonRatingForm: ButtonRatingFrom[] = [
{
name: "star",
icon: <StarIconMini width={"50px"} color={theme.palette.grey2.main} />,
icon: <StarIconMini width={50} color={theme.palette.grey2.main} />,
},
{ name: "trophie", icon: <TropfyIcon color={theme.palette.grey2.main} /> },
{ name: "flag", icon: <FlagIcon color={theme.palette.grey2.main} /> },

@ -71,7 +71,7 @@ export default function BranchingQuestions({
p: 0,
}}
>
<Box
{/* <Box
sx={{
boxSizing: "border-box",
background: "#F2F3F7",
@ -330,7 +330,7 @@ export default function BranchingQuestions({
Готово
</Button>
</Box>
</Box>
</Box> */}
</Box>
</Modal>
</>

@ -0,0 +1,127 @@
import { useState, useEffect } from "react";
import { Box, Typography, Button, useTheme } from "@mui/material";
import { useQuizViewStore } from "@root/quizView";
import type { QuizQuestionBase } from "../../model/questionTypes/shared";
type FooterProps = {
stepNumber: number;
setStepNumber: (step: number) => void;
questions: QuizQuestionBase[];
};
export const Footer = ({
stepNumber,
setStepNumber,
questions,
}: FooterProps) => {
const [disabledQuestionsId, setDisabledQuestionsId] = useState<Set<string>>(
new Set()
);
const { answers } = useQuizViewStore();
const theme = useTheme();
useEffect(() => {
clearDisabledQuestions();
const nextStepId = questions[stepNumber + 1].id;
const disabledIds = [] as string[];
const newDisabledIds = new Set([...disabledQuestionsId, ...disabledIds]);
setDisabledQuestionsId(newDisabledIds);
}, [answers]);
const clearDisabledQuestions = () => {
const cleanDisabledQuestions = new Set<string>();
answers.forEach(({ step, answer }) => {
questions[step].content.rule.main.forEach(({ next, rules }) => {
rules.forEach(({ answers }) => {
if (answer !== answers[0]) {
cleanDisabledQuestions.add(next);
}
});
});
});
setDisabledQuestionsId(cleanDisabledQuestions);
};
const followPreviousStep = () => {
setStepNumber(stepNumber - 1);
};
const followNextStep = () => {
setStepNumber(stepNumber + 1);
};
return (
<Box
sx={{
padding: "15px 0",
borderTop: `1px solid ${theme.palette.grey[400]}`,
}}
>
<Box
sx={{
width: "100%",
maxWidth: "1000px",
padding: "0 10px",
margin: "0 auto",
display: "flex",
alignItems: "center",
gap: "10px",
}}
>
<Box
sx={{
display: "flex",
alignItems: "center",
gap: "10px",
marginRight: "auto",
color: theme.palette.grey1.main,
}}
>
<Typography>Шаг</Typography>
<Typography
sx={{
display: "flex",
justifyContent: "center",
alignItems: "center",
fontWeight: "bold",
borderRadius: "50%",
width: "30px",
height: "30px",
color: "#FFF",
background: theme.palette.brightPurple.main,
}}
>
{stepNumber}
</Typography>
<Typography>Из</Typography>
<Typography sx={{ fontWeight: "bold" }}>
{questions.length}
</Typography>
</Box>
<Button
variant="contained"
disabled={stepNumber <= 1}
sx={{ fontSize: "16px", padding: "10px 15px" }}
onClick={followPreviousStep}
>
Назад
</Button>
<Button
variant="contained"
disabled={questions.length <= stepNumber}
sx={{ fontSize: "16px", padding: "10px 15px" }}
onClick={followNextStep}
>
Далее
</Button>
</Box>
</Box>
);
};

@ -0,0 +1,72 @@
import { Box } from "@mui/material";
import { Variant } from "./questions/Variant";
import { Images } from "./questions/Images";
import { Varimg } from "./questions/Varimg";
import { Emoji } from "./questions/Emoji";
import { Text } from "./questions/Text";
import { Select } from "./questions/Select";
import { Date } from "./questions/Date";
import { Number } from "./questions/Number";
import { File } from "./questions/File";
import { Page } from "./questions/Page";
import { Rating } from "./questions/Rating";
import { Footer } from "./Footer";
import type { FC } from "react";
import type { QuestionType } from "../../model/question/question";
import type { AnyTypedQuizQuestion } from "../../model/questionTypes/shared";
type QuestionProps = {
stepNumber: number;
setStepNumber: (step: number) => void;
questions: AnyTypedQuizQuestion[];
};
const QUESTIONS_MAP: Record<
Exclude<QuestionType, "nonselected">,
FC<{ stepNumber: number; question: any }>
> = {
variant: Variant,
images: Images,
varimg: Varimg,
emoji: Emoji,
text: Text,
select: Select,
date: Date,
number: Number,
file: File,
page: Page,
rating: Rating,
};
export const Question = ({
stepNumber,
setStepNumber,
questions,
}: QuestionProps) => {
const question = questions[stepNumber - 1] as AnyTypedQuizQuestion;
const QuestionComponent =
QUESTIONS_MAP[question.type as Exclude<QuestionType, "nonselected">];
return (
<Box>
<Box
sx={{
minHeight: "calc(100vh - 75px)",
width: "100%",
maxWidth: "1000px",
padding: "20px 10px 0",
margin: "0 auto",
}}
>
<QuestionComponent question={question} stepNumber={stepNumber} />
</Box>
<Footer
stepNumber={stepNumber}
setStepNumber={setStepNumber}
questions={questions}
/>
</Box>
);
};

@ -0,0 +1,159 @@
import {
Box,
Button,
Typography,
useTheme,
useMediaQuery,
} from "@mui/material";
import useSWR from "swr";
import { isAxiosError } from "axios";
import { enqueueSnackbar } from "notistack";
import { devlog } from "@frontend/kitui";
import { quizApi } from "@api/quiz";
import { useCurrentQuiz } from "@root/quizes/hooks";
import { setQuizes } from "@root/quizes/actions";
type StartPageViewPublicationProps = {
setStepNumber: (step: number) => void;
showNextButton: boolean;
};
export const StartPageViewPublication = ({
setStepNumber,
showNextButton,
}: StartPageViewPublicationProps) => {
const theme = useTheme();
const isTablet = useMediaQuery(theme.breakpoints.down(630));
const quiz = useCurrentQuiz();
const isMediaFileExist =
quiz?.config.startpage.background.desktop ||
quiz?.config.startpage.background.video;
useSWR("quizes", () => quizApi.getList(), {
onSuccess: setQuizes,
onError: (error: unknown) => {
const message = isAxiosError<string>(error)
? error.response?.data ?? ""
: "";
devlog("Error getting quiz list", error);
enqueueSnackbar(`Не удалось получить квизы. ${message}`);
},
});
return (
<Box
sx={{
height: "100vh",
display: "flex",
flexDirection:
quiz?.config.startpage.position === "left" ? "row" : "row-reverse",
flexGrow: 1,
"&::-webkit-scrollbar": { width: 0 },
}}
>
<Box
sx={{
width: isMediaFileExist && !isTablet ? "40%" : "100%",
padding: "16px",
display: "flex",
flexDirection: "column",
alignItems: isMediaFileExist && !isTablet ? "flex-start" : "center",
}}
>
<Box
sx={{
display: "flex",
alignItems: "center",
gap: "20px",
}}
>
{quiz?.config.startpage.background.mobile && (
<img
src={quiz.config.startpage.background.mobile}
style={{
height: "50px",
maxWidth: "100px",
objectFit: "cover",
}}
alt=""
/>
)}
<Typography sx={{ fontSize: "18px" }}>
{quiz?.config.info.orgname}
</Typography>
</Box>
<Box
sx={{
flexGrow: 1,
display: "flex",
gap: "10px",
flexDirection: "column",
justifyContent: "center",
}}
>
<Typography sx={{ fontWeight: "bold", fontSize: "20px" }}>
{quiz?.name}
</Typography>
<Typography sx={{ fontSize: "16px" }}>
{quiz?.config.startpage.description}
</Typography>
<Box>
<Button
variant="contained"
sx={{
fontSize: "16px",
padding: "10px 15px",
}}
onClick={() => setStepNumber(1)}
>
{quiz?.config.startpage.button
? quiz?.config.startpage.button
: "Пройти тест"}
</Button>
</Box>
</Box>
<Box>
<Typography
sx={{ fontSize: "16px", color: theme.palette.brightPurple.main }}
>
{quiz?.config.info.phonenumber}
</Typography>
<Typography sx={{ fontSize: "12px" }}>
{quiz?.config.info.law}
</Typography>
</Box>
</Box>
{!isTablet && isMediaFileExist && (
<Box sx={{ width: "60%" }}>
{quiz?.config.startpage.background.mobile && (
<img
src={quiz.config.startpage.background.mobile}
alt=""
style={{
display: "block",
width: "100%",
height: "100%",
objectFit: "cover",
}}
/>
)}
{quiz.config.startpage.background.type === "video" &&
quiz.config.startpage.background.video && (
<video
src={quiz.config.startpage.background.video}
controls
style={{
width: "100%",
height: "100%",
objectFit: "cover",
}}
/>
)}
</Box>
)}
</Box>
);
};

@ -0,0 +1,42 @@
import { useLayoutEffect, useState } from "react";
import { Box } from "@mui/material";
import { StartPageViewPublication } from "./StartPageViewPublication";
import { Question } from "./Question";
import { useQuestions } from "@root/questions/hooks";
import { useCurrentQuiz } from "@root/quizes/hooks";
import type { AnyTypedQuizQuestion } from "../../model/questionTypes/shared";
export const ViewPage = () => {
const [stepNumber, setStepNumber] = useState<number>(0);
const quiz = useCurrentQuiz();
const { questions } = useQuestions();
useLayoutEffect(() => {
if (stepNumber === 0 && quiz?.config.noStartPage) {
setStepNumber(1);
}
}, []);
const filteredQuestions = questions.filter(
({ type }) => type
) as AnyTypedQuizQuestion[];
return (
<Box>
{stepNumber ? (
<Question
stepNumber={stepNumber}
setStepNumber={setStepNumber}
questions={filteredQuestions}
/>
) : (
<StartPageViewPublication
setStepNumber={setStepNumber}
showNextButton={!!filteredQuestions.length}
/>
)}
</Box>
);
};

@ -0,0 +1,42 @@
import { useState } from "react";
import { useParams } from "react-router-dom";
import DatePicker from "react-datepicker";
import { Box, Typography } from "@mui/material";
import { questionStore, updateQuestionsList } from "@root/questions";
import "react-datepicker/dist/react-datepicker.css";
import type { QuizQuestionDate } from "../../../model/questionTypes/date";
type DateProps = {
question: QuizQuestionDate;
};
export const Date = ({ question }: DateProps) => {
const [startDate, setStartDate] = useState<Date | null>(new window.Date());
const quizId = Number(useParams().quizId);
const { listQuestions } = questionStore();
const totalIndex = listQuestions[quizId].findIndex(
({ id }) => question.id === id
);
return (
<Box>
<Typography variant="h5">{question.title}</Typography>
<Box
sx={{
display: "flex",
flexDirection: "column",
width: "100%",
marginTop: "20px",
}}
>
<DatePicker
selected={startDate}
onChange={(date) => setStartDate(date)}
/>
</Box>
</Box>
);
};

@ -0,0 +1,78 @@
import { useState } from "react";
import { useParams } from "react-router-dom";
import {
Box,
Typography,
RadioGroup,
FormControlLabel,
Radio,
useTheme,
} from "@mui/material";
import { questionStore, updateQuestionsList } from "@root/questions";
import RadioCheck from "@ui_kit/RadioCheck";
import RadioIcon from "@ui_kit/RadioIcon";
import type { QuizQuestionEmoji } from "../../../model/questionTypes/emoji";
type EmojiProps = {
question: QuizQuestionEmoji;
};
export const Emoji = ({ question }: EmojiProps) => {
const [valueIndex, setValueIndex] = useState<number>(0);
const quizId = Number(useParams().quizId);
const { listQuestions } = questionStore();
const theme = useTheme();
const totalIndex = listQuestions[quizId].findIndex(
({ id }) => question.id === id
);
return (
<Box>
<Typography variant="h5">{question.title}</Typography>
<RadioGroup
name={question.id}
value={valueIndex}
onChange={({ target }) => setValueIndex(Number(target.value))}
sx={{
display: "flex",
flexWrap: "wrap",
flexDirection: "row",
justifyContent: "space-between",
marginTop: "20px",
}}
>
<Box sx={{ display: "flex", flexDirection: "column", width: "100%" }}>
{question.content.variants.map(
({ id, answer, extendedText }, index) => (
<FormControlLabel
key={id}
sx={{
marginBottom: "15px",
borderRadius: "5px",
padding: "15px",
color: theme.palette.grey2.main,
border: `1px solid ${theme.palette.grey2.main}`,
display: "flex",
gap: "10px",
}}
value={index}
control={
<Radio checkedIcon={<RadioCheck />} icon={<RadioIcon />} />
}
label={
<Box sx={{ display: "flex", gap: "10px" }}>
<Typography>{extendedText}</Typography>
<Typography>{answer}</Typography>
</Box>
}
/>
)
)}
</Box>
</RadioGroup>
</Box>
);
};

@ -0,0 +1,64 @@
import { useState } from "react";
import { useParams } from "react-router-dom";
import { Box, Typography, ButtonBase } from "@mui/material";
import UploadBox from "@ui_kit/UploadBox";
import { questionStore, updateQuestionsList } from "@root/questions";
import { UPLOAD_FILE_TYPES_MAP } from "@ui_kit/QuizPreview/QuizPreviewQuestionTypes/File";
import UploadIcon from "@icons/UploadIcon";
import type { ChangeEvent } from "react";
import type { QuizQuestionFile } from "../../../model/questionTypes/file";
type FileProps = {
question: QuizQuestionFile;
};
export const File = ({ question }: FileProps) => {
const [fileName, setFileName] = useState<string>("");
const [file, setFile] = useState<string>();
const quizId = Number(useParams().quizId);
const { listQuestions } = questionStore();
const totalIndex = listQuestions[quizId].findIndex(
({ id }) => question.id === id
);
const uploadFile = ({ target }: ChangeEvent<HTMLInputElement>) => {
const file = target.files?.[0];
if (file) {
setFileName(file.name);
setFile(URL.createObjectURL(file));
}
};
return (
<Box>
<Typography variant="h5">{question.title}</Typography>
<Box
sx={{
display: "flex",
flexDirection: "column",
width: "100%",
marginTop: "20px",
}}
>
<ButtonBase component="label" sx={{ justifyContent: "flex-start" }}>
<input
onChange={uploadFile}
hidden
accept={UPLOAD_FILE_TYPES_MAP[question.content.type]}
multiple
type="file"
/>
<UploadBox icon={<UploadIcon />} text="5 MB максимум" />
</ButtonBase>
{fileName && (
<Typography sx={{ marginTop: "15px" }}>{fileName}</Typography>
)}
</Box>
</Box>
);
};

@ -0,0 +1,110 @@
import { useState } from "react";
import { useParams } from "react-router-dom";
import {
Box,
Typography,
RadioGroup,
FormControlLabel,
Radio,
useTheme,
useMediaQuery,
} from "@mui/material";
import { questionStore, updateQuestionsList } from "@root/questions";
import RadioCheck from "@ui_kit/RadioCheck";
import RadioIcon from "@ui_kit/RadioIcon";
import type { QuizQuestionImages } from "../../../model/questionTypes/images";
type ImagesProps = {
question: QuizQuestionImages;
};
export const Images = ({ question }: ImagesProps) => {
const [valueIndex, setValueIndex] = useState<number>(0);
const quizId = Number(useParams().quizId);
const { listQuestions } = questionStore();
const theme = useTheme();
const totalIndex = listQuestions[quizId].findIndex(
({ id }) => question.id === id
);
const isTablet = useMediaQuery(theme.breakpoints.down(1000));
const isMobile = useMediaQuery(theme.breakpoints.down(500));
return (
<Box>
<Typography variant="h5">{question.title}</Typography>
<RadioGroup
name={question.id}
value={valueIndex}
onChange={({ target }) => setValueIndex(Number(target.value))}
sx={{
display: "flex",
flexWrap: "wrap",
flexDirection: "row",
justifyContent: "space-between",
marginTop: "20px",
}}
>
<Box
sx={{
display: "grid",
gap: "15px",
gridTemplateColumns: isTablet
? isMobile
? "repeat(1, 1fr)"
: "repeat(2, 1fr)"
: "repeat(3, 1fr)",
width: "100%",
}}
>
{question.content.variants.map(
({ id, answer, extendedText }, index) => (
<Box
key={index}
sx={{
borderRadius: "5px",
border: `1px solid ${theme.palette.grey2.main}`,
}}
>
<Box
sx={{ display: "flex", alignItems: "center", gap: "10px" }}
>
<Box sx={{ width: "100%", height: "300px" }}>
{extendedText && (
<img
src={extendedText}
alt=""
style={{
display: "block",
width: "100%",
height: "100%",
objectFit: "cover",
}}
/>
)}
</Box>
</Box>
<FormControlLabel
key={id}
sx={{
display: "block",
textAlign: "center",
color: theme.palette.grey2.main,
marginTop: "10px",
}}
value={index}
control={
<Radio checkedIcon={<RadioCheck />} icon={<RadioIcon />} />
}
label={answer}
/>
</Box>
)
)}
</Box>
</RadioGroup>
</Box>
);
};

@ -0,0 +1,61 @@
import { useState } from "react";
import { useParams } from "react-router-dom";
import { Box, Typography, Slider, useTheme } from "@mui/material";
import CustomTextField from "@ui_kit/CustomTextField";
import { questionStore, updateQuestionsList } from "@root/questions";
import type { QuizQuestionNumber } from "../../../model/questionTypes/number";
type NumberProps = {
question: QuizQuestionNumber;
};
export const Number = ({ question }: NumberProps) => {
const [value, setValue] = useState<number>(0);
const quizId = window.Number(useParams().quizId);
const { listQuestions } = questionStore();
const theme = useTheme();
const totalIndex = listQuestions[quizId].findIndex(
({ id }) => question.id === id
);
return (
<Box>
<Typography variant="h5">{question.title}</Typography>
<Box
sx={{
display: "flex",
flexDirection: "column",
width: "100%",
marginTop: "20px",
}}
>
<CustomTextField
placeholder="0"
value={String(value)}
sx={{
maxWidth: "80px",
"& .MuiInputBase-input": {
textAlign: "center",
},
}}
/>
<Slider
value={value}
min={window.Number(question.content.range.split("—")[0])}
max={window.Number(question.content.range.split("—")[1])}
sx={{
color: theme.palette.brightPurple.main,
padding: "0",
marginTop: "25px",
}}
onChange={(_, value) => {
setValue(value as number);
}}
/>
</Box>
</Box>
);
};

@ -0,0 +1,57 @@
import { useParams } from "react-router-dom";
import { Box, Typography } from "@mui/material";
import { questionStore, updateQuestionsList } from "@root/questions";
import type { QuizQuestionPage } from "../../../model/questionTypes/page";
type PageProps = {
question: QuizQuestionPage;
};
export const Page = ({ question }: PageProps) => {
const quizId = Number(useParams().quizId);
const { listQuestions } = questionStore();
const totalIndex = listQuestions[quizId].findIndex(
({ id }) => question.id === id
);
return (
<Box>
<Typography variant="h5">{question.title}</Typography>
<Box
sx={{
display: "flex",
flexDirection: "column",
width: "100%",
marginTop: "20px",
}}
>
{question.content.picture && (
<img
src={question.content.picture}
alt=""
style={{
display: "block",
width: "100%",
height: "100%",
maxHeight: "80vh",
objectFit: "contain",
}}
/>
)}
{question.content.video && (
<video
src={question.content.video}
controls
style={{
width: "100%",
height: "100%",
maxHeight: "80vh",
objectFit: "contain",
}}
/>
)}
</Box>
</Box>
);
};

@ -0,0 +1,70 @@
import { useParams } from "react-router-dom";
import {
Box,
Typography,
Rating as RatingComponent,
useTheme,
} from "@mui/material";
import { questionStore, updateQuestionsList } from "@root/questions";
import StarIconMini from "@icons/questionsPage/StarIconMini";
import type { QuizQuestionRating } from "../../../model/questionTypes/rating";
type RatingProps = {
question: QuizQuestionRating;
};
export const Rating = ({ question }: RatingProps) => {
const quizId = Number(useParams().quizId);
const { listQuestions } = questionStore();
const totalIndex = listQuestions[quizId].findIndex(
({ id }) => question.id === id
);
const theme = useTheme();
return (
<Box>
<Typography variant="h5">{question.title}</Typography>
<Box
sx={{
display: "flex",
flexDirection: "column",
width: "100%",
marginTop: "20px",
}}
>
<RatingComponent
sx={{ height: "50px" }}
max={question.content.steps}
icon={
<StarIconMini
color={theme.palette.brightPurple.main}
width={50}
sx={{ transform: "scale(0.8)" }}
/>
}
emptyIcon={
<StarIconMini
color={theme.palette.grey2.main}
width={50}
sx={{ transform: "scale(0.8)" }}
/>
}
/>
<Box
sx={{
display: "flex",
justifyContent: "space-between",
maxWidth: `${question.content.steps * 50}px`,
color: theme.palette.grey2.main,
}}
>
<Typography>{question.content.ratingNegativeDescription}</Typography>
<Typography>{question.content.ratingPositiveDescription}</Typography>
</Box>
</Box>
</Box>
);
};

@ -0,0 +1,40 @@
import { Box, Typography } from "@mui/material";
import { useParams } from "react-router-dom";
import { Select as SelectComponent } from "../../../pages/Questions/Select";
import { questionStore, updateQuestionsList } from "@root/questions";
import type { QuizQuestionSelect } from "../../../model/questionTypes/select";
type SelectProps = {
question: QuizQuestionSelect;
};
export const Select = ({ question }: SelectProps) => {
const quizId = Number(useParams().quizId);
const { listQuestions } = questionStore();
const totalIndex = listQuestions[quizId].findIndex(
({ id }) => question.id === id
);
return (
<Box>
<Typography variant="h5">{question.title}</Typography>
<Box
sx={{
display: "flex",
flexDirection: "column",
width: "100%",
marginTop: "20px",
}}
>
<SelectComponent
items={question.content.variants.map(({ answer }) => answer)}
onChange={(action, num) => {
}}
/>
</Box>
</Box>
);
};

@ -0,0 +1,36 @@
import { useParams } from "react-router-dom";
import { Box, Typography } from "@mui/material";
import CustomTextField from "@ui_kit/CustomTextField";
import { questionStore, updateQuestionsList } from "@root/questions";
import type { QuizQuestionText } from "../../../model/questionTypes/text";
type TextProps = {
question: QuizQuestionText;
};
export const Text = ({ question }: TextProps) => {
const quizId = Number(useParams().quizId);
const { listQuestions } = questionStore();
const totalIndex = listQuestions[quizId].findIndex(
({ id }) => question.id === id
);
return (
<Box>
<Typography variant="h5">{question.title}</Typography>
<Box
sx={{
display: "flex",
flexDirection: "column",
width: "100%",
marginTop: "20px",
}}
>
<CustomTextField placeholder={question.content.placeholder} />
</Box>
</Box>
);
};

@ -0,0 +1,91 @@
import { useEffect } from "react";
import {
Box,
Typography,
RadioGroup,
FormControlLabel,
Radio,
useTheme,
} from "@mui/material";
import { useQuizViewStore, updateAnswer } from "@root/quizView";
import RadioCheck from "@ui_kit/RadioCheck";
import RadioIcon from "@ui_kit/RadioIcon";
import type { QuizQuestionVariant } from "../../../model/questionTypes/variant";
type VariantProps = {
stepNumber: number;
question: QuizQuestionVariant;
};
export const Variant = ({ stepNumber, question }: VariantProps) => {
const { answers } = useQuizViewStore();
const theme = useTheme();
const answerIndex = answers.findIndex(({ step }) => step === stepNumber);
const answer = answers[answerIndex]?.answer;
useEffect(() => {
if (!answer) {
updateAnswer(stepNumber, question.content.variants[0].id);
}
}, []);
return (
<Box>
<Typography variant="h5">{question.title}</Typography>
<Box sx={{ display: "flex" }}>
<RadioGroup
name={question.id}
value={question.content.variants.findIndex(({ id }) => answer === id)}
onChange={({ target }) =>
updateAnswer(
stepNumber,
question.content.variants[Number(target.value)].id
)
}
sx={{
display: "flex",
flexWrap: "wrap",
flexDirection: "row",
justifyContent: "space-between",
flexBasis: "100%",
marginTop: "20px",
}}
>
<Box sx={{ display: "flex", flexDirection: "column", width: "100%" }}>
{question.content.variants.map(({ id, answer }, index) => (
<FormControlLabel
key={id}
sx={{
marginBottom: "15px",
borderRadius: "5px",
padding: "15px",
color: theme.palette.grey2.main,
border: `1px solid ${theme.palette.grey2.main}`,
display: "flex",
gap: "10px",
}}
value={index}
control={
<Radio checkedIcon={<RadioCheck />} icon={<RadioIcon />} />
}
label={answer}
/>
))}
</Box>
</RadioGroup>
{question.content.back && (
<Box sx={{ maxWidth: "400px", width: "100%", height: "300px" }}>
<img
src={question.content.back}
style={{ width: "100%", height: "100%", objectFit: "cover" }}
alt=""
/>
</Box>
)}
</Box>
</Box>
);
};

@ -0,0 +1,87 @@
import { useState } from "react";
import { useParams } from "react-router-dom";
import {
Box,
Typography,
RadioGroup,
FormControlLabel,
Radio,
useTheme,
} from "@mui/material";
import { questionStore } from "@root/questions";
import RadioCheck from "@ui_kit/RadioCheck";
import RadioIcon from "@ui_kit/RadioIcon";
import type { QuizQuestionVarImg } from "../../../model/questionTypes/varimg";
type VarimgProps = {
question: QuizQuestionVarImg;
};
export const Varimg = ({ question }: VarimgProps) => {
const [valueIndex, setValueIndex] = useState<number>(-1);
const quizId = Number(useParams().quizId);
const { listQuestions } = questionStore();
const theme = useTheme();
const totalIndex = listQuestions[quizId].findIndex(
({ id }) => question.id === id
);
return (
<Box>
<Typography variant="h5">{question.title}</Typography>
<Box sx={{ display: "flex" }}>
<RadioGroup
name={question.id}
value={valueIndex}
onChange={({ target }) => setValueIndex(Number(target.value))}
sx={{
display: "flex",
flexWrap: "wrap",
flexDirection: "row",
justifyContent: "space-between",
flexBasis: "100%",
marginTop: "20px",
}}
>
<Box sx={{ display: "flex", flexDirection: "column", width: "100%" }}>
{question.content.variants.map(({ id, answer }, index) => (
<FormControlLabel
key={id}
sx={{
marginBottom: "15px",
borderRadius: "5px",
padding: "15px",
color: theme.palette.grey2.main,
border: `1px solid ${theme.palette.grey2.main}`,
display: "flex",
}}
value={index}
control={
<Radio checkedIcon={<RadioCheck />} icon={<RadioIcon />} />
}
label={answer}
/>
))}
</Box>
</RadioGroup>
{(question.content.variants[valueIndex]?.extendedText ||
question.content.back) && (
<Box sx={{ maxWidth: "400px", width: "100%", height: "300px" }}>
<img
src={
valueIndex >= 0
? question.content.variants[valueIndex].extendedText
: question.content.back
}
style={{ width: "100%", height: "100%", objectFit: "cover" }}
alt=""
/>
</Box>
)}
</Box>
</Box>
);
};

35
src/stores/quizView.ts Normal file

@ -0,0 +1,35 @@
import { create } from "zustand";
import { devtools } from "zustand/middleware";
type Answer = {
step: number;
answer: string;
};
interface QuizViewStore {
answers: Answer[];
}
export const useQuizViewStore = create<QuizViewStore>()(
devtools(
(set, get) => ({
answers: [],
}),
{
name: "quizView",
}
)
);
export const updateAnswer = (step: number, answer: string) => {
const answers = [...useQuizViewStore.getState().answers];
const answerIndex = answers.findIndex((answer) => step === answer.step);
if (answerIndex < 0) {
answers.push({ step, answer });
} else {
answers[answerIndex] = { step, answer };
}
useQuizViewStore.setState({ answers });
};

@ -1,7 +1,7 @@
{
"extends": "./tsconfig.extend.json",
"compilerOptions": {
"target": "es5",
"target": "es2015",
"lib": [
"dom",
"dom.iterable",

@ -1041,6 +1041,13 @@
dependencies:
regenerator-runtime "^0.14.0"
"@babel/runtime@^7.21.0":
version "7.23.5"
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.23.5.tgz#11edb98f8aeec529b82b211028177679144242db"
integrity sha512-NdUTHcPe4C99WxPub+K9l9tK5/lV4UXIoaHSYgzco9BCyjKAAwzdBI+wWtYqHt7LJdbo74ZjRPJgzVweq1sz0w==
dependencies:
regenerator-runtime "^0.14.0"
"@babel/template@^7.18.10", "@babel/template@^7.20.7", "@babel/template@^7.3.3":
version "7.20.7"
resolved "https://registry.npmjs.org/@babel/template/-/template-7.20.7.tgz"
@ -1883,7 +1890,7 @@
schema-utils "^3.0.0"
source-map "^0.7.3"
"@popperjs/core@^2.11.6", "@popperjs/core@^2.11.8":
"@popperjs/core@^2.11.6", "@popperjs/core@^2.11.8", "@popperjs/core@^2.9.2":
version "2.11.8"
resolved "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz"
integrity sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==
@ -2419,6 +2426,16 @@
"@types/cytoscape" "*"
"@types/react" "*"
"@types/react-datepicker@^4.19.3":
version "4.19.3"
resolved "https://registry.yarnpkg.com/@types/react-datepicker/-/react-datepicker-4.19.3.tgz#0a58e42d820adf12337617bd72289766643775db"
integrity sha512-85F9eKWu9fGiD9r4KVVMPYAdkJJswR3Wci9PvqplmB6T+D+VbUqPeKtifg96NZ4nEhufjehW+SX4JLrEWVplWw==
dependencies:
"@popperjs/core" "^2.9.2"
"@types/react" "*"
date-fns "^2.0.1"
react-popper "^2.2.5"
"@types/react-dnd@^3.0.2":
version "3.0.2"
resolved "https://registry.npmjs.org/@types/react-dnd/-/react-dnd-3.0.2.tgz"
@ -3607,6 +3624,11 @@ cjs-module-lexer@^1.0.0:
resolved "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.2.2.tgz"
integrity sha512-cOU9usZw8/dXIXKtwa8pM0OTJQuJkxMN6w30csNRUerHfeQ5R6U3kkU/FtJeIf3M202OHfY2U8ccInBG7/xogA==
classnames@^2.2.6:
version "2.3.2"
resolved "https://registry.yarnpkg.com/classnames/-/classnames-2.3.2.tgz#351d813bf0137fcc6a76a16b88208d2560a0d924"
integrity sha512-CSbhY4cFEJRe6/GQzIk5qXZ4Jeg5pcsP7b5peFSDpffpe1cqjASH/n9UTjBwOp6XpMSTwQ8Za2K5V02ueA7Tmw==
clean-css@^5.2.2:
version "5.3.1"
resolved "https://registry.npmjs.org/clean-css/-/clean-css-5.3.1.tgz"
@ -4199,6 +4221,13 @@ data-urls@^2.0.0:
whatwg-mimetype "^2.3.0"
whatwg-url "^8.0.0"
date-fns@^2.0.1, date-fns@^2.30.0:
version "2.30.0"
resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-2.30.0.tgz#f367e644839ff57894ec6ac480de40cae4b0f4d0"
integrity sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==
dependencies:
"@babel/runtime" "^7.21.0"
dayjs@^1.10.4, dayjs@^1.11.10:
version "1.11.10"
resolved "https://registry.npmjs.org/dayjs/-/dayjs-1.11.10.tgz"
@ -7093,7 +7122,7 @@ log-update@^4.0.0:
slice-ansi "^4.0.0"
wrap-ansi "^6.2.0"
loose-envify@^1.1.0, loose-envify@^1.4.0:
loose-envify@^1.0.0, loose-envify@^1.1.0, loose-envify@^1.4.0:
version "1.4.0"
resolved "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz"
integrity sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==
@ -8522,6 +8551,18 @@ react-cytoscapejs@^2.0.0:
dependencies:
prop-types "^15.8.1"
react-datepicker@^4.24.0:
version "4.24.0"
resolved "https://registry.yarnpkg.com/react-datepicker/-/react-datepicker-4.24.0.tgz#dfb12e277993f1ae2d350b7ba4dd6bba7d21bfb1"
integrity sha512-2QUC2pP+x4v3Jp06gnFllxKsJR0yoT/K6y86ItxEsveTXUpsx+NBkChWXjU0JsGx/PL8EQnsxN0wHl4zdA1m/g==
dependencies:
"@popperjs/core" "^2.11.8"
classnames "^2.2.6"
date-fns "^2.30.0"
prop-types "^15.7.2"
react-onclickoutside "^6.13.0"
react-popper "^2.3.0"
react-dev-utils@^12.0.1:
version "12.0.1"
resolved "https://registry.npmjs.org/react-dev-utils/-/react-dev-utils-12.0.1.tgz"
@ -8604,6 +8645,11 @@ react-fast-compare@^2.0.1:
resolved "https://registry.yarnpkg.com/react-fast-compare/-/react-fast-compare-2.0.4.tgz#e84b4d455b0fec113e0402c329352715196f81f9"
integrity sha512-suNP+J1VU1MWFKcyt7RtjiSWUjvidmQSlqu+eHslq+342xCbGTYmC0mEhPCOHxlW0CywylOC1u2DFAT+bv4dBw==
react-fast-compare@^3.0.1:
version "3.2.2"
resolved "https://registry.yarnpkg.com/react-fast-compare/-/react-fast-compare-3.2.2.tgz#929a97a532304ce9fee4bcae44234f1ce2c21d49"
integrity sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ==
react-image-crop@^10.1.5:
version "10.1.8"
resolved "https://registry.npmjs.org/react-image-crop/-/react-image-crop-10.1.8.tgz"
@ -8629,6 +8675,19 @@ react-is@^18.0.0, react-is@^18.2.0:
resolved "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz"
integrity sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==
react-onclickoutside@^6.13.0:
version "6.13.0"
resolved "https://registry.yarnpkg.com/react-onclickoutside/-/react-onclickoutside-6.13.0.tgz#e165ea4e5157f3da94f4376a3ab3e22a565f4ffc"
integrity sha512-ty8So6tcUpIb+ZE+1HAhbLROvAIJYyJe/1vRrrcmW+jLsaM+/powDRqxzo6hSh9CuRZGSL1Q8mvcF5WRD93a0A==
react-popper@^2.2.5, react-popper@^2.3.0:
version "2.3.0"
resolved "https://registry.yarnpkg.com/react-popper/-/react-popper-2.3.0.tgz#17891c620e1320dce318bad9fede46a5f71c70ba"
integrity sha512-e1hj8lL3uM+sgSR4Lxzn5h1GxBlpa4CQz0XLF8kx4MDrDRWY0Ena4c97PUeSX9i5W3UAfDP0z0FXCTQkoXUl3Q==
dependencies:
react-fast-compare "^3.0.1"
warning "^4.0.2"
react-redux@^7.2.0:
version "7.2.9"
resolved "https://registry.npmjs.org/react-redux/-/react-redux-7.2.9.tgz"
@ -10201,6 +10260,13 @@ walker@^1.0.7:
dependencies:
makeerror "1.0.12"
warning@^4.0.2:
version "4.0.3"
resolved "https://registry.yarnpkg.com/warning/-/warning-4.0.3.tgz#16e9e077eb8a86d6af7d64aa1e05fd85b4678ca3"
integrity sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==
dependencies:
loose-envify "^1.0.0"
watchpack@^2.4.0:
version "2.4.0"
resolved "https://registry.npmjs.org/watchpack/-/watchpack-2.4.0.tgz"