Merge branch 'staging'

This commit is contained in:
Nastya 2024-02-17 13:52:57 +03:00
commit 9108148a6e
296 changed files with 5405 additions and 10456 deletions

@ -1,4 +1,5 @@
/dist /dist
/dist-package
/widget /widget
Makefile Makefile
README.md README.md

@ -26,8 +26,10 @@ module.exports = {
"@typescript-eslint/no-unsafe-assignment": "off", "@typescript-eslint/no-unsafe-assignment": "off",
"@typescript-eslint/no-unsafe-call": "off", "@typescript-eslint/no-unsafe-call": "off",
"@typescript-eslint/no-unsafe-member-access": "off", "@typescript-eslint/no-unsafe-member-access": "off",
"@typescript-eslint/no-unused-vars": "warn", "@typescript-eslint/no-unused-vars": [
"@typescript-eslint/require-await": "warn", "warn",
{ "vars": "all", "args": "none" }
],
"@typescript-eslint/restrict-template-expressions": "off", "@typescript-eslint/restrict-template-expressions": "off",
"no-debugger": "off", "no-debugger": "off",
"no-empty-function": "off", "no-empty-function": "off",

3
.gitignore vendored

@ -9,6 +9,7 @@ lerna-debug.log*
node_modules node_modules
dist dist
dist-package
dist-ssr dist-ssr
widget widget
*.local *.local
@ -22,4 +23,4 @@ widget
*.ntvs* *.ntvs*
*.njsproj *.njsproj
*.sln *.sln
*.sw? *.sw?

52
README.md Normal file

@ -0,0 +1,52 @@
## Виджет
### Сборка
```bash
yarn build:widget
```
### Использование
```html
<script type="module">
import widget from "https://s.hbpn.link/export/pub.js";
widget.create({
selector: "widget-container",
quizId: "...",
})
</script>
```
## Npm-пакет
### Перед использованием и публикацией
```bash
npm config set //penahub.gitlab.yandexcloud.net/api/v4/packages/npm/:_authToken=INSTANCE_TOKEN
npm config set //penahub.gitlab.yandexcloud.net/api/v4/projects/43/packages/npm/:_authToken=PROJECT_TOKEN
```
### Публикация
1. Инкрементировать версию в package.json
2.
```bash
yarn publish
```
3. Нажать enter при запросе версии
### Установка
Добавить в корень проекта файл .yarnrc с содержимым
```
"@frontend:registry" "https://penahub.gitlab.yandexcloud.net/api/v4/packages/npm/"
```
```bash
yarn add @frontend/squzanswerer
```
Peer dependencies:
```bash
yarn add @emoji-mart/data @emoji-mart/react @emotion/react @emotion/styled @mui/icons-material @mui/material @mui/x-date-pickers axios emoji-mart immer moment nanoid notistack react-dom react-error-boundary react-router-dom react swr use-debounce zustand
```
### Использование
```ts
import { QuizView } from "@frontend/squzanswerer";
export default function Component() {
// ...
return (
<QuizView quizId={quizId} />
}
}
```

152
lib/api/quizRelase.ts Normal file

@ -0,0 +1,152 @@
import { GetQuizDataResponse, parseQuizData } from "@model/api/getQuizData";
import axios from "axios";
import type { AxiosError } from "axios";
import { replaceSpacesToEmptyLines } from "../components/ViewPublicationPage/tools/replaceSpacesToEmptyLines";
import { QuizSettings } from "@model/settingsData";
let SESSIONS = "";
const domain = location.hostname ==="localhost" ? "https://s.hbpn.link" : ""
export const publicationMakeRequest = ({ url, body }: any) => {
return axios(url, {
data: body,
headers: {
"X-Sessionkey": SESSIONS,
"Content-Type": "multipart/form-data",
},
method: "POST",
});
};
export async function getData(quizId: string): Promise<{
data: GetQuizDataResponse | null;
isRecentlyCompleted: boolean;
error?: string;
}> {
try {
const { data, headers } = await axios<GetQuizDataResponse>(
domain + `/answer/settings`,
{
method: "POST",
headers: {
"X-Sessionkey": SESSIONS,
"Content-Type": "application/json",
},
data: {
quiz_id: quizId,
limit: 100,
page: 0,
need_config: true,
},
}
);
const sessions = JSON.parse(localStorage.getItem("sessions") || "{}");
if (typeof sessions[quizId] === "number") {
// unix время. Если меньше суток прошло - выводить ошибку, иначе пустить дальше
if (Date.now() - sessions[quizId] < 86400000) {
return { data, isRecentlyCompleted: true };
}
}
SESSIONS = headers["x-sessionkey"] ? headers["x-sessionkey"] : SESSIONS;
return { data, isRecentlyCompleted: false };
} catch (nativeError) {
const error = nativeError as AxiosError;
return { data: null, isRecentlyCompleted: false, error: error.message };
}
}
export async function getQuizData(quizId: string) {
const response = await getData(quizId);
const quizDataResponse = response.data;
if (response.error) {
throw new Error(response.error);
}
if (!quizDataResponse) {
throw new Error("Quiz not found");
}
const quizSettings = replaceSpacesToEmptyLines(parseQuizData(quizDataResponse));
const res = JSON.parse(JSON.stringify({ data: quizSettings }).replaceAll(/\\" \\"/g, '""').replaceAll(/" "/g, '""')).data as QuizSettings;
res.recentlyCompleted = response.isRecentlyCompleted;
return res;
}
export function sendAnswer({ questionId, body, qid }: any) {
const formData = new FormData();
const answers = [
{
question_id: questionId,
content: body, //тут массив с ответом
},
];
formData.append("answers", JSON.stringify(answers));
console.log("QID", qid)
formData.append("qid", qid);
return publicationMakeRequest({
url: domain + `/answer/answer`,
body: formData,
method: "POST",
});
}
//body ={file, filename}
export function sendFile({ questionId, body, qid }: any) {
const formData = new FormData();
const answers: any = [
{
question_id: questionId,
content: "file:" + body.name,
},
];
formData.append("answers", JSON.stringify(answers));
formData.append(body.name, body.file);
console.log("QID", qid)
formData.append("qid", qid);
return publicationMakeRequest({
url: domain + `/answer/answer`,
body: formData,
method: "POST",
});
}
//форма контактов
export function sendFC({ questionId, body, qid }: any) {
const formData = new FormData();
// const keysBody = Object.keys(body)
// const content:any = {}
// fields.forEach((key) => {
// if (keysBody.includes(key)) content[key] = body.key
// })
const answers = [
{
question_id: questionId,
content: JSON.stringify(body),
result: true,
qid,
},
];
formData.append("answers", JSON.stringify(answers));
formData.append("qid", qid);
return publicationMakeRequest({
url: domain + `/answer/answer`,
body: formData,
method: "POST",
});
}

@ -0,0 +1,10 @@
export default function BlankImage() {
return (
<svg width="100%" height="100%" viewBox="0 -70 800 535" fill="none" display="block" preserveAspectRatio="xMidYMax meet" xmlns="http://www.w3.org/2000/svg">
<path fill="#F0F0F0" d="M555 47a47.003 47.003 0 0 1 29.014-43.422 46.999 46.999 0 0 1 61.408 61.408 46.997 46.997 0 0 1-76.656 15.248A47 47 0 0 1 555 47Z" />
<path fill="#F3F3F3" d="M641.874 240.665c7.74-7.74 20.263-7.82 28.102-.181L1051 611.837 779.035 883.805 383.869 498.67l258.005-258.005Z" />
<path fill="#EDEDED" d="M183.393 61.546c7.692-7.037 19.499-6.985 27.129.12l677.42 630.746-690.929 382.738L-397 592.531 183.393 61.546Z" />
</svg>
);
}

@ -21,4 +21,4 @@ export const NameplateLogoFQDark: FC<SVGProps<SVGSVGElement>> = (props) => (
</defs> </defs>
</svg> </svg>
); );

@ -0,0 +1,78 @@
import { QuizDataContext } from "@contexts/QuizDataContext";
import { QuizSettings } from "@model/settingsData";
import { Box, CssBaseline, ThemeProvider } from "@mui/material";
import { LocalizationProvider } from "@mui/x-date-pickers";
import { AdapterMoment } from "@mui/x-date-pickers/AdapterMoment";
import { ruRU } from '@mui/x-date-pickers/locales';
import { handleComponentError } from "@utils/handleComponentError";
import lightTheme from "@utils/themes/light";
import moment from "moment";
import { SnackbarProvider } from 'notistack';
import { ErrorBoundary } from "react-error-boundary";
import { ApologyPage } from "./ViewPublicationPage/ApologyPage";
import ViewPublicationPage from "./ViewPublicationPage/ViewPublicationPage";
import { RootContainerWidthContext } from "@contexts/RootContainerWidthContext";
import { startTransition, useEffect, useLayoutEffect, useRef, useState } from "react";
moment.locale("ru");
const localeText = ruRU.components.MuiLocalizationProvider.defaultProps.localeText;
type Props = {
quizSettings: QuizSettings;
quizId: string;
preview?: boolean;
};
export default function QuizAnswerer({ quizSettings, quizId, preview = false }: Props) {
const [rootContainerWidth, setRootContainerWidth] = useState<number>(() => window.innerWidth);
const rootContainerRef = useRef<HTMLDivElement>(null);
useLayoutEffect(() => {
if (rootContainerRef.current) setRootContainerWidth(rootContainerRef.current.clientWidth);
}, []);
useEffect(() => {
const handleWindowResize = () => {
startTransition(() => {
if (rootContainerRef.current) setRootContainerWidth(rootContainerRef.current.clientWidth);
});
};
window.addEventListener("resize", handleWindowResize);
return () => {
window.removeEventListener("resize", handleWindowResize);
};
}, []);
return (
<RootContainerWidthContext.Provider value={rootContainerWidth}>
<QuizDataContext.Provider value={{ ...quizSettings, quizId, preview }}>
<LocalizationProvider dateAdapter={AdapterMoment} adapterLocale="ru" localeText={localeText}>
<ThemeProvider theme={lightTheme}>
<SnackbarProvider
preventDuplicate={true}
style={{ backgroundColor: lightTheme.palette.brightPurple.main }}
>
<CssBaseline />
<Box
ref={rootContainerRef}
sx={{
width: "100%",
height: "100%",
}}
>
<ErrorBoundary
FallbackComponent={ApologyPage}
onError={handleComponentError}
>
<ViewPublicationPage />
</ErrorBoundary>
</Box>
</SnackbarProvider>
</ThemeProvider>
</LocalizationProvider>
</QuizDataContext.Provider>
</RootContainerWidthContext.Provider>
);
}

@ -0,0 +1,28 @@
import { Box, Typography } from "@mui/material";
import { FallbackProps } from "react-error-boundary";
type Props = Partial<FallbackProps>;
export const ApologyPage = ({ error }: Props) => {
let message = "Что-то пошло не так";
if (error.message === "No questions found") message = "Нет созданных вопросов";
if (error.message === "Quiz already completed") message = "Вы уже прошли этот опрос";
return (
<Box
sx={{
display: "flex",
alignItems: "center",
justifyContent: "center",
height: "100%",
}}
>
<Typography
sx={{
textAlign: "center",
}}
>{message}</Typography>
</Box>
);
};

@ -0,0 +1,436 @@
import AddressIcon from "@icons/ContactFormIcon/AddressIcon";
import EmailIcon from "@icons/ContactFormIcon/EmailIcon";
import NameIcon from "@icons/ContactFormIcon/NameIcon";
import PhoneIcon from "@icons/ContactFormIcon/PhoneIcon";
import TextIcon from "@icons/ContactFormIcon/TextIcon";
import { Box, Button, InputAdornment, Link, TextField as MuiTextField, TextFieldProps, Typography, useTheme } from "@mui/material";
import CustomCheckbox from "@ui_kit/CustomCheckbox";
import { FC, useRef, useState } from "react";
import { sendFC } from "@api/quizRelase";
import { NameplateLogo } from "@icons/NameplateLogo";
import { QuizQuestionResult } from "@model/questionTypes/result";
import { AnyTypedQuizQuestion } from "@model/questionTypes/shared";
import { quizThemes } from "@utils/themes/Publication/themePublication";
import { enqueueSnackbar } from "notistack";
import { useRootContainerSize } from "../../contexts/RootContainerWidthContext";
import { useQuizData } from "@contexts/QuizDataContext";
const TextField = MuiTextField as unknown as FC<TextFieldProps>; // temporary fix ts(2590)
const EMAIL_REGEXP = /^(([^<>()[\].,:\s@"]+(\.[^<>()[\].,:\s@"]+)*)|(".+"))@(([^<>()[\].,:\s@"]+\.)+[^<>()[\].,:\s@"]{2,})$/iu;
type Props = {
currentQuestion: AnyTypedQuizQuestion;
onShowResult: () => void;
};
export const ContactForm = ({ currentQuestion, onShowResult }: Props) => {
const theme = useTheme();
const { settings, questions, quizId } = useQuizData();
const [ready, setReady] = useState(false);
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [phone, setPhone] = useState("");
const [text, setText] = useState("");
const [adress, setAdress] = useState("");
const fireOnce = useRef(true);
const [fire, setFire] = useState(false);
const isMobile = useRootContainerSize() < 850;
const resultQuestion = currentQuestion.type === "result"
? currentQuestion
: questions.find((question): question is QuizQuestionResult => {
if (settings?.cfg.haveRoot) {
return (
question.type === "result" &&
question.content.rule.parentId === currentQuestion.content.id
);
} else {
return (
question.type === "result" && question.content.rule.parentId === "line"
);
}
});
if (!resultQuestion) throw new Error("Result question not found");
const inputHC = async () => {
const FC = settings.cfg.formContact.fields || settings.cfg.formContact;
const body = {} as any;
if (name.length > 0) body.name = name;
if (email.length > 0) body.email = email;
if (phone.length > 0) body.phone = phone;
if (adress.length > 0) body.address = adress;
if (text.length > 0) body.customs = { [FC.text.text || "Фамилия"]: text };
if (Object.keys(body).length > 0) {
try {
await sendFC({
questionId: currentQuestion.id,
body: body,
qid: quizId,
});
const sessions = JSON.parse(localStorage.getItem("sessions") || "{}");
localStorage.setItem(
"sessions",
JSON.stringify({ ...sessions, [quizId]: new Date().getTime() })
);
} catch (e) {
enqueueSnackbar("ответ не был засчитан");
}
}
};
const FCcopy: any = settings.cfg.formContact.fields || settings.cfg.formContact;
const filteredFC: any = {};
for (const i in FCcopy) {
const field = FCcopy[i];
if (field.used) {
filteredFC[i] = field;
}
}
const isWide = Object.keys(filteredFC).length > 2;
async function handleShowResultsClick() {
//@ts-ignore
const FC: any = settings.cfg.formContact.fields || settings.cfg.formContact;
if (FC["email"].used !== EMAIL_REGEXP.test(email)) {
return enqueueSnackbar("введена некорректная почта");
}
if (fireOnce.current) {
if (
name.length === 0
&& email.length === 0
&& phone.length === 0
&& text.length === 0
&& adress.length === 0
) return enqueueSnackbar("Пожалуйста, заполните поля");
//почта валидна, хоть одно поле заполнено
setFire(true);
try {
await inputHC();
fireOnce.current = false;
const sessions: any = JSON.parse(
localStorage.getItem("sessions") || "{}"
);
sessions[quizId] = Date.now();
localStorage.setItem(
"sessions",
JSON.stringify(sessions)
);
enqueueSnackbar("Данные успешно отправлены");
} catch (e) {
enqueueSnackbar("повторите попытку позже");
}
onShowResult();
}
setFire(false);
}
return (
<Box
sx={{
display: "flex",
alignItems: "center",
justifyContent: "center",
backgroundColor: theme.palette.background.default,
height: "100%",
overflow: "auto",
"&::-webkit-scrollbar": {
width: "0",
display: "none",
msOverflowStyle: "none",
},
scrollbarWidth: "none",
msOverflowStyle: "none",
}}
>
<Box
sx={{
width: isWide && !isMobile ? "100%" : isMobile ? undefined : "530px",
borderRadius: "4px",
height: "90vh",
display: isWide && !isMobile ? "flex" : undefined,
}}
>
<Box
sx={{
width: isWide && !isMobile ? "100%" : undefined,
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
borderRight: isWide && !isMobile ? "1px solid gray" : undefined,
}}
>
<Typography
sx={{
textAlign: "center",
m: "20px 0",
fontSize: "28px",
color: theme.palette.text.primary,
wordBreak: "break-word"
}}
>
{settings.cfg.formContact.title ||
"Заполните форму, чтобы получить результаты теста"}
</Typography>
{settings.cfg.formContact.desc && (
<Typography
sx={{
color: theme.palette.text.primary,
textAlign: "center",
m: "20px 0",
fontSize: "18px",
wordBreak: "break-word"
}}
>
{settings.cfg.formContact.desc}
</Typography>
)}
</Box>
<Box
sx={{
display: "flex",
alignItems: "center",
justifyContent: "center",
flexDirection: "column",
backgroundColor: theme.palette.background.default,
p: "30px",
}}
>
<Box
sx={{
display: "flex",
flexDirection: "column",
my: "20px",
}}
>
<Inputs
name={name}
setName={setName}
email={email}
setEmail={setEmail}
phone={phone}
setPhone={setPhone}
text={text}
setText={setText}
adress={adress}
setAdress={setAdress}
/>
</Box>
{
// resultQuestion &&
// settings.cfg.resultInfo.when === "after" &&
<Button
disabled={!(ready && !fire)}
variant="contained"
onClick={handleShowResultsClick}
>
{settings.cfg.formContact?.button || "Получить результаты"}
</Button>
}
<Box
sx={{
display: "flex",
mt: "20px",
width: isMobile ? "300px" : "450px",
}}
>
<CustomCheckbox
label=""
handleChange={({ target }) => {
setReady(target.checked);
}}
checked={ready}
colorIcon={theme.palette.primary.main}
/>
<Typography sx={{ color: theme.palette.text.primary }}>
С&ensp;
<Link href={"https://shub.pena.digital/ppdd"} target="_blank">
Положением об обработке персональных данных{" "}
</Link>
&ensp;и&ensp;
<Link
href={"https://shub.pena.digital/docs/privacy"}
target="_blank"
>
{" "}
Политикой конфиденциальности{" "}
</Link>
&ensp;ознакомлен
</Typography>
</Box>
<Box
component={Link}
target={"_blank"}
href={"https://quiz.pena.digital"}
sx={{
display: "flex",
alignItems: "center",
mt: "20px",
gap: "15px",
textDecoration: "none",
}}
>
<NameplateLogo
style={{
fontSize: "34px",
color: quizThemes[settings.cfg.theme].isLight ? "#151515" : "#FFFFFF",
}}
/>
<Typography
sx={{
fontSize: "20px",
color: quizThemes[settings.cfg.theme].isLight ? "#4D4D4D" : "#F5F7FF",
whiteSpace: "nowrap",
}}
>
Сделано на PenaQuiz
</Typography>
</Box>
</Box>
</Box>
</Box>
);
};
const Inputs = ({
name,
setName,
email,
setEmail,
phone,
setPhone,
text,
setText,
adress,
setAdress,
}: any) => {
const { settings } = useQuizData();
// @ts-ignore
const FC = settings.cfg.formContact.fields || settings.cfg.formContact;
if (!FC) return null;
//@ts-ignore
const Name = (
<CustomInput
//@ts-ignore
onChange={({ target }) => setName(target.value)}
id={name}
title={FC["name"].innerText || "Введите имя"}
desc={FC["name"].text || "имя"}
Icon={NameIcon}
/>
);
//@ts-ignore
const Email = (
<CustomInput
error={!EMAIL_REGEXP.test(email)}
label={!EMAIL_REGEXP.test(email) ? "" : "Некорректная почта"}
//@ts-ignore
onChange={({ target }) => setEmail(target.value)}
id={email}
title={FC["email"].innerText || "Введите Email"}
desc={FC["email"].text || "Email"}
Icon={EmailIcon}
/>
);
const Phone = (
<CustomInput
//@ts-ignore
onChange={({ target }) => setPhone(target.value)}
id={phone}
title={FC["phone"].innerText || "Введите номер телефона"}
desc={FC["phone"].text || "номер телефона"}
Icon={PhoneIcon}
/>
);
//@ts-ignore
const Text = (
<CustomInput
//@ts-ignore
onChange={({ target }) => setText(target.value)}
id={text}
title={FC["text"].text || "Введите фамилию"}
desc={FC["text"].innerText || "фамилию"}
Icon={TextIcon}
/>
);
//@ts-ignore
const Adress = (
<CustomInput
//@ts-ignore
onChange={({ target }) => setAdress(target.value)}
id={adress}
title={FC["address"].innerText || "Введите адрес"}
desc={FC["address"].text || "адрес"}
Icon={AddressIcon}
/>
);
//@ts-ignore
if (Object.values(FC).some((data) => data.used)) {
return (
<>
{FC["name"].used ? Name : <></>}
{FC["email"].used ? Email : <></>}
{FC["phone"].used ? Phone : <></>}
{FC["text"].used ? Text : <></>}
{FC["address"].used ? Adress : <></>}
</>
);
} else {
return (
<>
{Name}
{Email}
{Phone}
</>
);
}
};
const CustomInput = ({ title, desc, Icon, onChange }: any) => {
const theme = useTheme();
const isMobile = useRootContainerSize() < 600;
//@ts-ignore
return (
<Box m="15px 0">
<Typography mb="7px" color={theme.palette.text.primary}>
{title}
</Typography>
<TextField
onChange={onChange}
sx={{
width: isMobile ? "300px" : "350px",
}}
placeholder={desc}
InputProps={{
startAdornment: (
<InputAdornment position="start">
<Icon color="gray" />
</InputAdornment>
),
}}
/>
</Box>
);
};

@ -0,0 +1,111 @@
import { useQuizData } from "@contexts/QuizDataContext";
import { Box, Typography, useTheme } from "@mui/material";
import { ReactNode } from "react";
type FooterProps = {
stepNumber: number | null;
nextButton: ReactNode;
prevButton: ReactNode;
};
export const Footer = ({ stepNumber, nextButton, prevButton }: FooterProps) => {
const theme = useTheme();
const { questions } = useQuizData();
console.log(questions)
return (
<Box
sx={{
position: "relative",
padding: "15px 0",
borderTop: `1px solid ${theme.palette.grey[400]}`,
height: '75px',
display: "flex",
}}
>
<Box
sx={{
width: "100%",
maxWidth: "1000px",
padding: "0 10px",
margin: "0 auto",
display: "flex",
alignItems: "center",
gap: "10px",
}}
>
{/*{mode[settings.cfg.theme] ? (*/}
{/* <NameplateLogoFQ style={{ fontSize: "34px", width:"200px", height:"auto" }} />*/}
{/*):(*/}
{/* <NameplateLogoFQDark style={{ fontSize: "34px", width:"200px", height:"auto" }} />*/}
{/*)}*/}
{stepNumber !== null &&
<Box
sx={{
display: "flex",
alignItems: "center",
gap: "10px",
marginRight: "auto",
color: theme.palette.text.primary,
}}
>
<Typography>Шаг</Typography>
<Typography
sx={{
display: "flex",
justifyContent: "center",
alignItems: "center",
fontWeight: "bold",
borderRadius: "50%",
width: "30px",
height: "30px",
color: "#FFF",
background: theme.palette.primary.main,
}}
>
{stepNumber}
</Typography>
<Typography>Из</Typography>
<Typography sx={{ fontWeight: "bold" }}>
{questions.filter(q => q.type !== "result").length}
</Typography>
</Box>
}
<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>
{prevButton}
{nextButton}
</Box>
</Box>
);
};

@ -0,0 +1,97 @@
import {Box, Link, useTheme} from "@mui/material";
import { Footer } from "./Footer";
import { Date } from "./questions/Date";
import { Emoji } from "./questions/Emoji";
import { File } from "./questions/File";
import { Images } from "./questions/Images";
import { Number } from "./questions/Number";
import { Page } from "./questions/Page";
import { Rating } from "./questions/Rating";
import { Select } from "./questions/Select";
import { Text } from "./questions/Text";
import { Variant } from "./questions/Variant";
import { Varimg } from "./questions/Varimg";
import type { RealTypedQuizQuestion } from "../../model/questionTypes/shared";
import { NameplateLogoFQ } from "@icons/NameplateLogoFQ";
import { NameplateLogoFQDark } from "@icons/NameplateLogoFQDark";
import { useQuizData } from "@contexts/QuizDataContext";
import { notReachable } from "@utils/notReachable";
import { quizThemes } from "@utils/themes/Publication/themePublication";
import { ReactNode } from "react";
import { useRootContainerSize } from "../../contexts/RootContainerWidthContext";
type Props = {
currentQuestion: RealTypedQuizQuestion;
currentQuestionStepNumber: number | null;
nextButton: ReactNode;
prevButton: ReactNode;
};
export const Question = ({
currentQuestion,
currentQuestionStepNumber,
nextButton,
prevButton,
}: Props) => {
const theme = useTheme();
const { settings } = useQuizData();
const isMobile = useRootContainerSize() < 650;
console.log(settings)
return (
<Box sx={{
backgroundColor: theme.palette.background.default,
height: isMobile ? "100%" : "100vh"
}}>
<Box sx={{
height: "calc(100% - 75px)",
width: "100%",
maxWidth: "1440px",
padding: "40px 25px 20px",
margin: "0 auto",
overflow: "auto",
display: "flex",
flexDirection: "column",
justifyContent: "space-between"
}}>
<QuestionByType key={currentQuestion.id} question={currentQuestion} />
{quizThemes[settings.cfg.theme].isLight ? (
<Link target={"_blank"} href={"https://quiz.pena.digital"}>
<NameplateLogoFQ style={{ fontSize: "34px", width: "200px", height: "auto" }} />
</Link>
) : (
<Link target={"_blank"} href={"https://quiz.pena.digital"}>
<NameplateLogoFQDark style={{ fontSize: "34px", width: "200px", height: "auto" }} />
</Link>
)}
</Box>
<Footer
stepNumber={currentQuestionStepNumber}
prevButton={prevButton}
nextButton={nextButton}
/>
</Box>
);
};
function QuestionByType({ question }: {
question: RealTypedQuizQuestion;
}) {
switch (question.type) {
case "variant": return <Variant currentQuestion={question} />;
case "images": return <Images currentQuestion={question} />;
case "varimg": return <Varimg currentQuestion={question} />;
case "emoji": return <Emoji currentQuestion={question} />;
case "text": return <Text currentQuestion={question} />;
case "select": return <Select currentQuestion={question} />;
case "date": return <Date currentQuestion={question} />;
case "number": return <Number currentQuestion={question} />;
case "file": return <File currentQuestion={question} />;
case "page": return <Page currentQuestion={question} />;
case "rating": return <Rating currentQuestion={question} />;
default: notReachable(question);
}
}

@ -0,0 +1,203 @@
import {
Box,
Button, Link,
Typography,
useTheme
} from "@mui/material";
import { NameplateLogo } from "@icons/NameplateLogo";
import YoutubeEmbedIframe from "./tools/YoutubeEmbedIframe";
import { useQuizData } from "@contexts/QuizDataContext";
import { quizThemes } from "@utils/themes/Publication/themePublication";
import { useRootContainerSize } from "../../contexts/RootContainerWidthContext";
import type { QuizQuestionResult } from "../../model/questionTypes/result";
import { setCurrentQuizStep } from "@stores/quizView";
type ResultFormProps = {
resultQuestion: QuizQuestionResult;
};
export const ResultForm = ({ resultQuestion }: ResultFormProps) => {
const theme = useTheme();
const isMobile = useRootContainerSize() < 650;
const { settings } = useQuizData();
return (
<Box
sx={{
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "space-between",
height: "100%",
width: "100vw",
pt: "28px",
overflow: "auto",
}}
>
<Box
sx={{
display: "flex",
flexDirection: "column",
alignItems: "start",
width: isMobile ? "100%" : "490px",
padding: isMobile ? "0 16px" : undefined,
}}
>
{
!resultQuestion?.content.useImage && resultQuestion.content.video && (
<YoutubeEmbedIframe
videoUrl={resultQuestion.content.video}
containerSX={{
width: isMobile ? "100%" : "490px",
height: isMobile ? "100%" : "280px",
}}
/>
)
}
{
resultQuestion?.content.useImage && resultQuestion.content.back && (
<Box
component="img"
src={resultQuestion.content.back}
sx={{
width: isMobile ? "100%" : "490px",
height: isMobile ? "100%" : "280px",
}}
></Box>
)
}
{resultQuestion.description !== "" &&
resultQuestion.description !== " " && (
<Typography
sx={{
fontSize: "23px",
fontWeight: 700,
m: "20px 0",
color: theme.palette.text.primary,
wordBreak: "break-word"
}}
>
{resultQuestion.description}
</Typography>
)}
<Typography
sx={{
m: "20px 0",
color: theme.palette.text.primary,
wordBreak: "break-word"
}}
>
{resultQuestion.title}
</Typography>
{
resultQuestion.content.text !== "" &&
resultQuestion.content.text !== " " && (
<Typography
sx={{
fontSize: "18px",
m: "20px 0",
wordBreak: "break-word",
color: theme.palette.text.primary,
}}
>
{
resultQuestion.content.text
}
</Typography>
)
}
</Box>
<Box width="100%">
<Box
sx={{
display: "flex",
width: "100%",
justifyContent: "end",
px: "20px",
}}
>
<Box
component={Link}
target={"_blank"}
href={"https://quiz.pena.digital"}
sx={{
display: "flex",
alignItems: "center",
mt: "15px",
gap: "10px",
textDecoration: "none",
mb: "5px"
}}
>
<NameplateLogo
style={{
fontSize: "34px",
color: quizThemes[settings.cfg.theme].isLight ? "#000000" : "#F5F7FF",
}}
/>
<Typography
sx={{
fontSize: "20px",
color: quizThemes[settings.cfg.theme].isLight ? "#4D4D4D" : "#F5F7FF",
whiteSpace: "nowrap",
}}
>
Сделано на PenaQuiz
</Typography>
</Box>
</Box>
<Box
sx={{
boxShadow: "0 0 15px 0 rgba(0,0,0,.08)",
width: "100%",
flexDirection: "column",
display: "flex",
justifyContent: "center",
alignItems: "center",
p:
(
settings.cfg.resultInfo.showResultForm === "before" &&
!Boolean(settings.cfg.score)
) ||
(
settings.cfg.resultInfo.showResultForm === "after" &&
resultQuestion.content.redirect
)
? "20px" : "0",
}}
>
{settings.cfg.resultInfo.showResultForm === "before" && !Boolean(settings.cfg.score) && (
<Button
onClick={() => setCurrentQuizStep("contactform")}
variant="contained"
sx={{
p: "10px 20px",
width: "auto",
height: "50px",
}}
>
{resultQuestion.content.hint.text || "Узнать подробнее"}
</Button>
)}
{settings.cfg.resultInfo.showResultForm === "after" &&
resultQuestion.content.redirect && (
<Button
href={resultQuestion.content.redirect}
variant="contained"
sx={{ p: "10px 20px", width: "auto", height: "50px" }}
>
{resultQuestion.content.hint.text || "Перейти на сайт"}
</Button>
)}
</Box>
</Box>
</Box>
);
};

@ -0,0 +1,490 @@
import { Box, Button, ButtonBase, Link, Paper, Typography, useTheme } from "@mui/material";
import { useUADevice } from "../../utils/hooks/useUADevice";
import { notReachable } from "../../utils/notReachable";
import YoutubeEmbedIframe from "./tools/YoutubeEmbedIframe";
import { NameplateLogo } from "@icons/NameplateLogo";
import { QuizStartpageAlignType, QuizStartpageType } from "@model/settingsData";
import { useQuizData } from "@contexts/QuizDataContext";
import { quizThemes } from "@utils/themes/Publication/themePublication";
import { useRootContainerSize } from "../../contexts/RootContainerWidthContext";
import { setCurrentQuizStep } from "@stores/quizView";
export const StartPageViewPublication = () => {
const theme = useTheme();
const { settings } = useQuizData();
const { isMobileDevice } = useUADevice();
const isMobile = useRootContainerSize() < 650;
const isTablet = useRootContainerSize() < 800;
const handleCopyNumber = () => {
navigator.clipboard.writeText(settings.cfg.info.phonenumber);
};
console.log(settings.cfg.startpage.background.type)
const background =
settings.cfg.startpage.background.type === "image" ? (
settings.cfg.startpage.background.desktop ? (
<img
src={settings.cfg.startpage.background.desktop}
alt=""
style={{
width: (isMobile || settings.cfg.startpageType === "expanded") ? "100%" : undefined,
height: "100%",
objectFit: "cover",
overflow: "hidden",
}}
/>
) : null
) : settings.cfg.startpage.background.type === "video" ? (
settings.cfg.startpage.background.video ? (
<YoutubeEmbedIframe
videoUrl={settings.cfg.startpage.background.video}
containerSX={{
width:
settings.cfg.startpageType === "centered"
? "550px"
: settings.cfg.startpageType === "expanded"
? "100vw"
: "100%",
height:
settings.cfg.startpageType === "centered"
? "275px"
: "100%",
borderRadius: settings.cfg.startpageType === "centered" ? "10px" : "0",
overflow: "hidden",
"& iframe": {
width: "100%",
height: "100%",
transform:
settings.cfg.startpageType === "centered"
? ""
: settings.cfg.startpageType === "expanded"
? "scale(1.5)"
: "scale(2.4)",
},
}}
/>
) : null
) : null;
return (
<Paper
className="settings-preview-draghandle"
sx={{
height: "100%",
width: "100%",
background:
settings.cfg.startpageType === "expanded" && !isMobile
? settings.cfg.startpage.position === "left"
? "linear-gradient(90deg,#272626,transparent)"
: settings.cfg.startpage.position === "center"
? "linear-gradient(180deg,transparent,#272626)"
: "linear-gradient(270deg,#272626,transparent)"
: theme.palette.background.default,
color: settings.cfg.startpageType === "expanded" ? "white" : "black",
}}
>
<QuizPreviewLayoutByType
quizHeaderBlock={
<Box p={settings.cfg.startpageType === "standard" ? "" : "16px"}>
<Box
sx={{
display: "flex",
alignItems: "center",
gap: "20px",
mb: "7px",
}}
>
{settings.cfg.startpage.logo && (
<img
src={settings.cfg.startpage.logo}
style={{
height: "37px",
maxWidth: "43px",
objectFit: "cover",
}}
alt=""
/>
)}
<Typography
sx={{
fontSize: "14px",
color: settings.cfg.startpageType === "expanded"
&& !isMobile ? "white" : theme.palette.text.primary,
wordBreak: "break-word"
}}
>{settings.cfg.info.orgname}</Typography>
</Box>
<Link mb="16px" href={settings.cfg.info.site}>
<Typography
sx={{
fontSize: "16px",
color: theme.palette.primary.main,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
maxWidth: isTablet ? "200px" : "300px"
}}>
{settings.cfg.info.site}
</Typography>
</Link>
</Box>
}
quizMainBlock={
<>
<Box
sx={{
display: "flex",
flexDirection: "column",
justifyContent: "center",
alignItems:
settings.cfg.startpageType === "centered"
? "center"
: settings.cfg.startpageType === "expanded"
? settings.cfg.startpage.position === "center"
? "center"
: "start"
: "start",
mt: "28px",
width: "100%",
}}
>
<Typography
sx={{
fontWeight: "bold",
fontSize: "26px",
fontStyle: "normal",
fontStretch: "normal",
lineHeight: "1.2",
overflowWrap: "break-word",
width: "100%",
textAlign: settings.cfg.startpageType === "centered" || settings.cfg.startpage.position === "center" ? "center" : "-moz-initial",
color: settings.cfg.startpageType === "expanded" && !isMobile ? "white" : theme.palette.text.primary
}}
>
{settings.name}
</Typography>
<Typography
sx={{
fontSize: "16px",
m: "16px 0",
overflowWrap: "break-word",
width: "100%",
textAlign: settings.cfg.startpageType === "centered" || settings.cfg.startpage.position === "center" ? "center" : "-moz-initial",
color: settings.cfg.startpageType === "expanded" && !isMobile ? "white" : theme.palette.text.primary
}}
>
{settings.cfg.startpage.description}
</Typography>
<Box width={settings.cfg.startpageType === "standard" ? "100%" : "auto"}>
<Button
variant="contained"
sx={{
fontSize: "16px",
padding: "10px 15px",
width: settings.cfg.startpageType === "standard" ? "100%" : "auto",
}}
onClick={() => setCurrentQuizStep("question")}
>
{settings.cfg.startpage.button.trim() ? settings.cfg.startpage.button : "Пройти тест"}
</Button>
</Box>
</Box>
<Box
sx={{
mt: "46px",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
width: "100%",
flexDirection: "column"
}}
>
<Box sx={{ maxWidth: isTablet ? "240px" : "300px" }}>
{settings.cfg.info.clickable ? (
isMobileDevice ? (
<Link href={`tel:${settings.cfg.info.phonenumber}`}>
<Typography sx={{ fontSize: "16px", color: theme.palette.primary.main }}>
{settings.cfg.info.phonenumber}
</Typography>
</Link>
) : (
<ButtonBase onClick={handleCopyNumber}>
<Typography sx={{ fontSize: "16px", color: theme.palette.primary.main }}>
{settings.cfg.info.phonenumber}
</Typography>
</ButtonBase>
)
) : (
<Typography sx={{ fontSize: "16px", color: theme.palette.primary.main }}>
{settings.cfg.info.phonenumber}
</Typography>
)}
<Typography sx={{
width: "100%",
overflowWrap: "break-word",
fontSize: "12px",
textAlign: "end",
maxHeight: "120px",
overflow: "auto",
color:
settings.cfg.startpageType === "expanded" && !isMobile
? "white"
: theme.palette.text.primary,
}}>
{settings.cfg.info.law}
</Typography>
</Box>
<Box
component={Link}
target={"_blank"}
href={"https://quiz.pena.digital"}
sx={{
display: "flex",
alignItems: "center",
gap: "15px",
textDecoration: "none"
}}
>
<NameplateLogo style={{ fontSize: "34px", color: settings.cfg.startpageType === "expanded" && !isMobile ? "#FFFFFF" : (quizThemes[settings.cfg.theme].isLight ? "#151515" : "#FFFFFF") }} />
<Typography sx={{ fontSize: "20px", color: settings.cfg.startpageType === "expanded" && !isMobile ? "#F5F7FF" : (quizThemes[settings.cfg.theme].isLight ? "#4D4D4D" : "#F5F7FF"), whiteSpace: "nowrap", }}>
Сделано на PenaQuiz
</Typography>
</Box>
</Box>
</>
}
backgroundBlock={background}
startpageType={settings.cfg.startpageType}
alignType={settings.cfg.startpage.position}
/>
</Paper>
);
};
function QuizPreviewLayoutByType({
quizHeaderBlock,
quizMainBlock,
backgroundBlock,
startpageType,
alignType,
}: {
quizHeaderBlock: JSX.Element;
quizMainBlock: JSX.Element;
backgroundBlock: JSX.Element | null;
startpageType: QuizStartpageType;
alignType: QuizStartpageAlignType;
}) {
const isMobile = useRootContainerSize() < 650;
function StartPageMobile() {
return (
<Box
sx={{
display: "flex",
flexDirection: "column-reverse",
flexGrow: 1,
justifyContent: "flex-end",
height: "100%",
"&::-webkit-scrollbar": { width: 0 },
}}
>
<Box
sx={{
width: "100%",
display: "flex",
flexDirection: "column",
justifyContent: "space-between",
alignItems: "flex-start",
p: "25px",
height: "80%",
overflowY: "auto",
overflowX: "hidden"
}}
>
{quizHeaderBlock}
<Box
sx={{
height: "80%",
display: "flex",
flexDirection: "column",
justifyContent: "space-between",
width: "100%"
}}
>
{quizMainBlock}
</Box>
</Box>
<Box
sx={{
width: "100%",
overflow: "hidden",
}}
>
{backgroundBlock}
</Box>
</Box>
);
}
switch (startpageType) {
case null:
case "standard": {
return (
<>
{isMobile ? (
<StartPageMobile />
) : (
<Box
id="pain"
sx={{
display: "flex",
flexDirection: alignType === "left" ? (isMobile ? "column-reverse" : "row") : "row-reverse",
flexGrow: 1,
justifyContent: isMobile ? "flex-end" : undefined,
height: "100%",
"&::-webkit-scrollbar": { width: 0 },
overflow: "auto"
}}
>
<Box
sx={{
width: isMobile ? "100%" : "40%",
display: "flex",
flexDirection: "column",
justifyContent: "space-between",
alignItems: "flex-start",
p: "25px",
height: isMobile ? "80%" : undefined
}}
>
{quizHeaderBlock}
{quizMainBlock}
</Box>
<Box
sx={{
width: isMobile ? "100%" : "60%",
overflow: "hidden",
}}
>
{backgroundBlock}
</Box>
</Box>
)}
</>
);
}
case "expanded": {
return (
<>
{isMobile ? (
<StartPageMobile />
) : (
<Box
sx={{
overflow: "auto",
position: "relative",
display: "flex",
justifyContent: startpageAlignTypeToJustifyContent[alignType],
flexGrow: 1,
height: "100%",
"&::-webkit-scrollbar": { width: 0 },
}}
>
<Box
sx={{
width: "40%",
position: "relative",
padding: "16px",
zIndex: 3,
display: "flex",
flexDirection: "column",
justifyContent: "space-between",
alignItems: alignType === "center" ? "center" : "start",
}}
>
{quizHeaderBlock}
{quizMainBlock}
</Box>
<Box
sx={{
position: "absolute",
zIndex: -1,
left: 0,
top: 0,
height: "100%",
width: "100%",
overflow: "hidden",
}}
>
{backgroundBlock}
</Box>
</Box>
)
}
</>
);
}
case "centered": {
return (
<>
{isMobile ? (
<StartPageMobile />
) : (
<Box
sx={{
overflow: "auto",
padding: "16px",
display: "flex",
flexDirection: "column",
justifyContent: "space-between",
alignItems: "center",
height: "100%",
"&::-webkit-scrollbar": { width: 0 },
}}
>
{quizHeaderBlock}
{backgroundBlock && (
<Box
sx={{
width: "60%",
height: "275px",
// overflow: "hidden",
display: "flex",
justifyContent: "center"
}}
>
{backgroundBlock}
</Box>
)}
{quizMainBlock}
</Box>
)
}
</>
);
}
default:
notReachable(startpageType);
}
}
const startpageAlignTypeToJustifyContent: Record<QuizStartpageAlignType, "start" | "center" | "end"> = {
left: "start",
center: "center",
right: "end",
};

@ -0,0 +1,98 @@
import { Button, ThemeProvider } from "@mui/material";
import { useQuizViewStore } from "@stores/quizView";
import { useQuestionFlowControl } from "@utils/hooks/useQuestionFlowControl";
import { useQuizData } from "@contexts/QuizDataContext";
import { notReachable } from "@utils/notReachable";
import { quizThemes } from "@utils/themes/Publication/themePublication";
import { ReactElement, useEffect } from "react";
import { useRootContainerSize } from "../../contexts/RootContainerWidthContext";
import { ContactForm } from "./ContactForm";
import { Question } from "./Question";
import { ResultForm } from "./ResultForm";
import { StartPageViewPublication } from "./StartPageViewPublication";
export default function ViewPublicationPage() {
const { settings, recentlyCompleted } = useQuizData();
let currentQuizStep = useQuizViewStore(state => state.currentQuizStep);
const isMobileMini = useRootContainerSize() < 382;
const {
currentQuestion,
currentQuestionStepNumber,
isNextButtonEnabled,
isPreviousButtonEnabled,
moveToPrevQuestion,
moveToNextQuestion,
showResultAfterContactForm,
} = useQuestionFlowControl();
useEffect(function setFaviconAndTitle() {
const link = document.querySelector('link[rel="icon"]');
if (link && settings.cfg.startpage.favIcon) {
link.setAttribute("href", settings.cfg.startpage.favIcon);
}
document.title = settings.name;
}, [settings]);
if (recentlyCompleted) throw new Error("Quiz already completed");
if (currentQuizStep === "startpage" && settings.cfg.noStartPage) currentQuizStep = "question";
let quizStepElement: ReactElement;
switch (currentQuizStep) {
case "startpage": {
quizStepElement = <StartPageViewPublication />;
break;
}
case "question": {
if (currentQuestion.type === "result") {
quizStepElement = <ResultForm resultQuestion={currentQuestion} />;
break;
}
quizStepElement = (
<Question
currentQuestion={currentQuestion}
currentQuestionStepNumber={currentQuestionStepNumber}
prevButton={
<Button
disabled={!isPreviousButtonEnabled}
variant="contained"
sx={{ fontSize: "16px", padding: "10px 15px" }}
onClick={moveToPrevQuestion}
>
{isMobileMini ? "←" : "← Назад"}
</Button>
}
nextButton={
<Button
disabled={!isNextButtonEnabled}
variant="contained"
sx={{ fontSize: "16px", padding: "10px 15px" }}
onClick={moveToNextQuestion}
>
Далее
</Button>
}
/>
);
break;
}
case "contactform": {
quizStepElement = (
<ContactForm
currentQuestion={currentQuestion}
onShowResult={showResultAfterContactForm}
/>
);
break;
}
default: notReachable(currentQuizStep);
}
return (
<ThemeProvider theme={quizThemes[settings.cfg.theme || "StandardTheme"].theme}>
{quizStepElement}
</ThemeProvider>
);
}

@ -0,0 +1,113 @@
import moment from "moment";
import { DatePicker } from "@mui/x-date-pickers";
import { Box, Typography, useTheme } from "@mui/material";
import { useQuizViewStore, updateAnswer } from "@stores/quizView";
import type { QuizQuestionDate } from "../../../model/questionTypes/date";
import CalendarIcon from "@icons/CalendarIcon";
import { enqueueSnackbar } from "notistack";
import { sendAnswer } from "@api/quizRelase";
import { quizThemes } from "@utils/themes/Publication/themePublication";
import { useQuizData } from "@contexts/QuizDataContext";
import { useState } from "react";
type DateProps = {
currentQuestion: QuizQuestionDate;
};
export const Date = ({ currentQuestion }: DateProps) => {
const theme = useTheme();
const { settings, quizId } = useQuizData();
const { answers } = useQuizViewStore();
const answer = answers.find(
({ questionId }) => questionId === currentQuestion.id
)?.answer as string;
const currentAnswer = moment(answer) || moment();
const [readySend, setReadySend] = useState(true)
return (
<Box>
<Typography variant="h5" color={theme.palette.text.primary} sx={{ wordBreak: "break-word" }}>
{currentQuestion.title}
</Typography>
<Box
sx={{
display: "flex",
flexDirection: "column",
width: "100%",
marginTop: "20px",
}}
>
<DatePicker
slots={{
openPickerIcon: () => (
<CalendarIcon
sx={{
"& path": { stroke: theme.palette.primary.main },
"& rect": { stroke: theme.palette.primary.main },
}}
/>
),
}}
value={currentAnswer}
onChange={async (date) => {
if (readySend) {
setReadySend(false)
if (!date) {
return;
}
try {
await sendAnswer({
questionId: currentQuestion.id,
body: moment(date).format("YYYY.MM.DD"),
qid: quizId,
});
updateAnswer(
currentQuestion.id,
date,
0
);
} catch (e) {
enqueueSnackbar("ответ не был засчитан");
}
setReadySend(true)
}
}}
slotProps={{
openPickerButton: {
sx: {
p: 0,
},
"data-cy": "open-datepicker",
},
layout: {
sx: { backgroundColor: theme.palette.background.default },
},
}}
sx={{
"& .MuiInputBase-root": {
backgroundColor: quizThemes[settings.cfg.theme].isLight
? "white"
: theme.palette.background.default,
borderRadius: "10px",
maxWidth: "250px",
pr: "22px",
"& input": {
py: "11px",
pl: "20px",
lineHeight: "19px",
},
"& fieldset": {
borderColor: "#9A9AAF",
},
},
}}
/>
</Box>
</Box>
);
};

@ -0,0 +1,192 @@
import {
Box,
FormControl,
FormControlLabel,
Radio,
RadioGroup,
Typography,
useTheme
} from "@mui/material";
import { deleteAnswer, updateAnswer, useQuizViewStore } from "@stores/quizView";
import RadioCheck from "@ui_kit/RadioCheck";
import RadioIcon from "@ui_kit/RadioIcon";
import { sendAnswer } from "@api/quizRelase";
import { enqueueSnackbar } from "notistack";
import type { QuizQuestionEmoji } from "../../../model/questionTypes/emoji";
import { useQuizData } from "@contexts/QuizDataContext";
import { polyfillCountryFlagEmojis } from "country-flag-emoji-polyfill";
polyfillCountryFlagEmojis();
import { useState } from "react";
type EmojiProps = {
currentQuestion: QuizQuestionEmoji;
};
export const Emoji = ({ currentQuestion }: EmojiProps) => {
const theme = useTheme();
const { quizId } = useQuizData();
const { answers } = useQuizViewStore();
const { answer } =
answers.find(
({ questionId }) => questionId === currentQuestion.id
) ?? {};
const [readySend, setReadySend] = useState(true)
return (
<Box>
<Typography
variant="h5"
color={theme.palette.text.primary}
sx={{ wordBreak: "break-word" }}
>{currentQuestion.title}</Typography>
<RadioGroup
name={currentQuestion.id}
value={currentQuestion.content.variants.findIndex(
({ id }) => answer === id
)}
onChange={({ target }) => {
updateAnswer(
currentQuestion.id,
currentQuestion.content.variants[Number(target.value)].answer,
currentQuestion.content.variants[Number(target.value)].points || 0
);
}
}
sx={{
display: "flex",
flexWrap: "wrap",
flexDirection: "row",
justifyContent: "space-between",
marginTop: "20px",
}}
>
<Box sx={{ display: "flex", width: "100%", gap: "42px", flexWrap: "wrap" }}>
{currentQuestion.content.variants.map((variant, index) => (
<FormControl
key={variant.id}
sx={{
borderRadius: "12px",
border: `1px solid`,
borderColor: answer === variant.id ? theme.palette.primary.main : "#9A9AAF",
overflow: "hidden",
maxWidth: "317px",
width: "100%",
height: "255px",
}}
>
<Box
sx={{
display: "flex",
alignItems: "center",
height: "193px",
background: "#ffffff",
}}
>
<Box
sx={{
width: "100%",
display: "flex",
justifyContent: "center",
}}
>
{variant.extendedText && (
<Typography fontSize={"100px"}>
{variant.extendedText}
</Typography>
)}
</Box>
</Box>
<FormControlLabel
key={variant.id}
sx={{
margin: 0,
padding: "15px",
color: theme.palette.text.primary,
display: "flex",
gap: "10px",
alignItems:
variant.answer.length <= 60 ? "center" : "flex-start",
position: "relative",
height: "80px",
"& .MuiFormControlLabel-label": {
wordBreak: "break-word",
height: variant.answer.length <= 60 ? undefined : "60px",
overflow: "auto",
paddingLeft: "45px",
"&::-webkit-scrollbar": {
width: "4px",
},
"&::-webkit-scrollbar-thumb": {
backgroundColor: "#b8babf",
}
},
}}
value={index}
onClick={async (event) => {
event.preventDefault();
if (readySend) {
setReadySend(false)
try {
await sendAnswer({
questionId: currentQuestion.id,
body: currentQuestion.content.variants[index].extendedText + " " + currentQuestion.content.variants[index].answer,
qid: quizId,
});
updateAnswer(
currentQuestion.id,
currentQuestion.content.variants[index].id,
currentQuestion.content.variants[index].points || 0
);
} catch (e) {
enqueueSnackbar("ответ не был засчитан");
}
if (answer === currentQuestion.content.variants[index].id) {
deleteAnswer(currentQuestion.id);
try {
await sendAnswer({
questionId: currentQuestion.id,
body: "",
qid: quizId,
});
} catch (e) {
enqueueSnackbar("ответ не был засчитан");
}
}
setReadySend(true)
}
}}
control={
<Radio checkedIcon={<RadioCheck color={theme.palette.primary.main} />} icon={<RadioIcon />} />
}
label={
<Box sx={{ display: "flex", gap: "10px" }}>
<Typography sx={{
wordBreak: "break-word",
lineHeight: "normal",
}}>{variant.answer}</Typography>
</Box>
}
/>
</FormControl>
))}
</Box>
</RadioGroup>
</Box>
);
};

@ -0,0 +1,288 @@
import {
Box,
ButtonBase,
IconButton,
Modal,
Skeleton,
Typography,
useTheme
} from "@mui/material";
import { updateAnswer, useQuizViewStore } from "@stores/quizView";
import CloseBold from "@icons/CloseBold";
import UploadIcon from "@icons/UploadIcon";
import { sendAnswer, sendFile } from "@api/quizRelase";
import { useQuizData } from "@contexts/QuizDataContext";
import Info from "@icons/Info";
import { enqueueSnackbar } from "notistack";
import { useState } from "react";
import { useRootContainerSize } from "../../../contexts/RootContainerWidthContext";
import type { QuizQuestionFile } from "../../../model/questionTypes/file";
import { ACCEPT_SEND_FILE_TYPES_MAP, MAX_FILE_SIZE, UPLOAD_FILE_DESCRIPTIONS_MAP } from "../tools/fileUpload";
type ModalWarningType = "errorType" | "errorSize" | "picture" | "video" | "audio" | "document" | null;
type FileProps = {
currentQuestion: QuizQuestionFile;
};
export const File = ({ currentQuestion }: FileProps) => {
const theme = useTheme();
const { answers } = useQuizViewStore();
const { quizId } = useQuizData();
const [modalWarningType, setModalWarningType] = useState<ModalWarningType>(null);
const [isSending, setIsSending] = useState<boolean>(false);
const [isDropzoneHighlighted, setIsDropzoneHighlighted] = useState<boolean>(false);
const isMobile = useRootContainerSize() < 500;
const answer = answers.find(
({ questionId }) => questionId === currentQuestion.id
)?.answer as string;
const uploadFile = async (file: File | undefined) => {
if (isSending) return;
if (!file) return;
console.log(file.size)
console.log(MAX_FILE_SIZE)
if (file.size > MAX_FILE_SIZE) return setModalWarningType("errorSize");
const isFileTypeAccepted = ACCEPT_SEND_FILE_TYPES_MAP[currentQuestion.content.type].some(
fileType => file.name.toLowerCase().endsWith(fileType)
);
if (!isFileTypeAccepted) return setModalWarningType("errorType");
setIsSending(true);
try {
const data = await sendFile({
questionId: currentQuestion.id,
body: {
file: file,
name: file.name
},
qid: quizId,
});
console.log(data);
await sendAnswer({
questionId: currentQuestion.id,
body: `https://storage.yandexcloud.net/squizanswer/${quizId}/${currentQuestion.id}/${data.data.fileIDMap[currentQuestion.id]}`,
qid: quizId,
});
updateAnswer(currentQuestion.id, `${file.name}|${URL.createObjectURL(file)}`, 0);
} catch (e) {
console.log(e);
enqueueSnackbar("ответ не был засчитан");
}
setIsSending(false);
};
const onDrop = (event: React.DragEvent<HTMLDivElement>) => {
event.preventDefault();
setIsDropzoneHighlighted(false);
const file = event.dataTransfer.files[0];
uploadFile(file);
};
return (
<Box>
<Typography
variant="h5"
color={theme.palette.text.primary}
sx={{ wordBreak: "break-word" }}
>{currentQuestion.title}</Typography>
<Box
sx={{
display: "flex",
flexDirection: "column",
width: "100%",
marginTop: "20px",
maxWidth: answer?.split("|")[0] ? "640px" : "600px",
}}
>
{answer?.split("|")[0] ? (
<Box sx={{ display: "flex", alignItems: "center", gap: "15px" }}>
<Typography color={theme.palette.text.primary}>Вы загрузили:</Typography>
<Box
sx={{
padding: "5px 5px 5px 16px",
backgroundColor: theme.palette.primary.main,
borderRadius: "8px",
color: "#FFFFFF",
display: "flex",
alignItems: "center",
overflow: "hidden",
gap: "15px",
}}
>
<Typography
sx={{
whiteSpace: "nowrap",
textOverflow: "ellipsis",
overflow: "hidden",
}}
>
{answer?.split("|")[0]}</Typography>
<IconButton
sx={{ p: 0 }}
onClick={async () => {
if (answer.length > 0) {
setIsSending(true);
await sendAnswer({
questionId: currentQuestion.id,
body: "",
qid: quizId,
});
}
console.log(answer);
updateAnswer(currentQuestion.id, "", 0);
setIsSending(false);
}}
>
<CloseBold />
</IconButton>
</Box>
</Box>
) : (
<Box
sx={{
display: "flex",
alignItems: "center"
}}
>
{isSending ?
<Skeleton
variant="rounded"
sx={{
width: "100%",
height: "120px",
maxWidth: "560px",
}}
/>
:
<ButtonBase
component="label"
sx={{ justifyContent: "flex-start", width: "100%" }}
>
<input
onChange={e => uploadFile(e.target.files?.[0])}
hidden
accept={ACCEPT_SEND_FILE_TYPES_MAP[currentQuestion.content.type].join(",")}
multiple
type="file"
/>
<Box
onDragEnter={() => !answer?.split("|")[0] && setIsDropzoneHighlighted(true)}
onDragLeave={() => setIsDropzoneHighlighted(false)}
onDragOver={(e) => e.preventDefault()}
onDrop={onDrop}
sx={{
width: "100%",
height: isMobile ? undefined : "120px",
display: "flex",
gap: "50px",
justifyContent: "flex-start",
alignItems: "center",
padding: "33px 44px 33px 55px",
backgroundColor: theme.palette.background.default,
border: `1px solid ${isDropzoneHighlighted ? "red" : "#9A9AAF"}`,
borderRadius: "8px",
}}
>
<UploadIcon />
<Box>
<Typography
sx={{
color: "#9A9AAF",
// color: theme.palette.grey2.main,
fontWeight: 500,
}}
>
{UPLOAD_FILE_DESCRIPTIONS_MAP[currentQuestion.content.type].title}
</Typography>
<Typography
sx={{
color: "#9A9AAF",
// color: theme.palette.grey2.main,
fontSize: "16px",
lineHeight: "19px",
}}
>
{UPLOAD_FILE_DESCRIPTIONS_MAP[currentQuestion.content.type].description}
</Typography>
</Box>
</Box>
</ButtonBase>
}
<Info
sx={{ width: "40px", height: "40px" }}
color={theme.palette.primary.main}
onClick={() => setModalWarningType(currentQuestion.content.type)}
/>
</Box>
)}
{answer && currentQuestion.content.type === "picture" && (
<img
src={answer.split("|")[1]}
alt=""
style={{
marginTop: "15px",
maxWidth: "300px",
maxHeight: "300px",
}}
/>
)}
{answer && currentQuestion.content.type === "video" && (
<video
src={answer.split("|")[1]}
style={{
marginTop: "15px",
maxWidth: "300px",
maxHeight: "300px",
objectFit: "cover",
}}
/>
)}
</Box>
<Modal
open={modalWarningType !== null}
onClose={() => setModalWarningType(null)}
>
<Box sx={{
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
width: isMobile ? 300 : 400,
bgcolor: 'background.paper',
borderRadius: 3,
boxShadow: 24,
p: 4,
}}>
<CurrentModal status={modalWarningType} />
</Box>
</Modal>
</Box>
);
};
const CurrentModal = ({ status }: { status: ModalWarningType; }) => {
switch (status) {
case null: return null;
case 'errorType': return <Typography>Выбран некорректный тип файла</Typography>;
case 'errorSize': return <Typography>Файл слишком большой. Максимальный размер 50 МБ</Typography>;
default: return (
<>
<Typography>Допустимые расширения файлов:</Typography>
<Typography>{
ACCEPT_SEND_FILE_TYPES_MAP[status].join(" ")}</Typography>
</>
);
}
};

@ -0,0 +1,163 @@
import {
Box,
FormControlLabel,
Radio,
RadioGroup,
Typography,
useTheme
} from "@mui/material";
import { deleteAnswer, updateAnswer, useQuizViewStore } from "@stores/quizView";
import RadioCheck from "@ui_kit/RadioCheck";
import RadioIcon from "@ui_kit/RadioIcon";
import { sendAnswer } from "@api/quizRelase";
import { enqueueSnackbar } from "notistack";
import { useRootContainerSize } from "../../../contexts/RootContainerWidthContext";
import type { QuizQuestionImages } from "../../../model/questionTypes/images";
import { useQuizData } from "@contexts/QuizDataContext";
type ImagesProps = {
currentQuestion: QuizQuestionImages;
};
export const Images = ({ currentQuestion }: ImagesProps) => {
const { quizId } = useQuizData();
const { answers } = useQuizViewStore();
const theme = useTheme();
const answer = answers.find(({ questionId }) => questionId === currentQuestion.id)?.answer;
const isTablet = useRootContainerSize() < 1000;
const isMobile = useRootContainerSize() < 500;
return (
<Box>
<Typography variant="h5" color={theme.palette.text.primary} sx={{ wordBreak: "break-word" }}>{currentQuestion.title}</Typography>
<RadioGroup
name={currentQuestion.id}
value={currentQuestion.content.variants.findIndex(
({ id }) => answer === id
)}
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%",
}}
>
{currentQuestion.content.variants.map((variant, index) => (
<Box
key={index}
sx={{
cursor: "pointer",
borderRadius: "5px",
border: `1px solid`,
borderColor: answer === variant.id ? theme.palette.primary.main : "#9A9AAF",
}}
onClick={async (event) => {
event.preventDefault();
try {
await sendAnswer({
questionId: currentQuestion.id,
body: `${currentQuestion.content.variants[index].answer} <img style="width:100%; max-width:250px; max-height:250px" src="${currentQuestion.content.variants[index].extendedText}"/>`,
qid: quizId,
});
updateAnswer(
currentQuestion.id,
currentQuestion.content.variants[index].id,
currentQuestion.content.variants[index].points || 0
);
} catch (e) {
enqueueSnackbar("ответ не был засчитан");
}
if (answer === currentQuestion.content.variants[index].id) {
deleteAnswer(currentQuestion.id);
try {
await sendAnswer({
questionId: currentQuestion.id,
body: "",
qid: quizId,
});
} catch (e) {
enqueueSnackbar("ответ не был засчитан");
}
}
}}
>
<Box sx={{ display: "flex", alignItems: "center", gap: "10px" }}>
<Box sx={{ width: "100%", height: "300px" }}>
{variant.extendedText && (
<img
src={variant.extendedText}
alt=""
style={{
display: "block",
width: "100%",
height: "100%",
objectFit: "cover",
}}
/>
)}
</Box>
</Box>
<FormControlLabel
key={variant.id}
sx={{
textAlign: "center",
color: theme.palette.text.primary,
marginTop: "10px",
marginLeft: 0,
padding: "10px",
display: "flex",
alignItems:
variant.answer.length <= 60 ? "center" : "flex-start",
position: "relative",
height: "80px",
"& .MuiFormControlLabel-label": {
wordBreak: "break-word",
height: variant.answer.length <= 60 ? undefined : "60px",
overflow: "auto",
lineHeight: "normal",
paddingLeft: "45px",
"&::-webkit-scrollbar": {
width: "4px",
},
"&::-webkit-scrollbar-thumb": {
backgroundColor: "#b8babf",
}
},
}}
value={index}
control={
<Radio checkedIcon={<RadioCheck color={theme.palette.primary.main} />} icon={<RadioIcon />} />
}
label={variant.answer}
/>
</Box>
))}
</Box>
</RadioGroup>
</Box>
);
};

@ -0,0 +1,454 @@
import { Box, Typography, useTheme } from "@mui/material";
import { useEffect, useState } from "react";
import { useDebouncedCallback } from "use-debounce";
import { CustomSlider } from "@ui_kit/CustomSlider";
import CustomTextField from "@ui_kit/CustomTextField";
import { updateAnswer, useQuizViewStore } from "@stores/quizView";
import { sendAnswer } from "@api/quizRelase";
import { enqueueSnackbar } from "notistack";
import type { QuizQuestionNumber } from "@model/questionTypes/number";
import { useQuizData } from "@contexts/QuizDataContext";
import { quizThemes } from "@utils/themes/Publication/themePublication";
import { useRootContainerSize } from "@contexts/RootContainerWidthContext";
import type { ChangeEvent, SyntheticEvent } from "react";
type NumberProps = {
currentQuestion: QuizQuestionNumber;
};
export const Number = ({ currentQuestion }: NumberProps) => {
const { settings, quizId } = useQuizData();
const [inputValue, setInputValue] = useState<string>("0");
const [minRange, setMinRange] = useState<string>("0");
const [maxRange, setMaxRange] = useState<string>("100000000000");
const [reversedInputValue, setReversedInputValue] = useState<string>("0");
const [reversedMinRange, setReversedMinRange] = useState<string>("0");
const [reversedMaxRange, setReversedMaxRange] =
useState<string>("100000000000");
const theme = useTheme();
const { answers } = useQuizViewStore();
const isMobile = useRootContainerSize() < 650;
const [minBorder, maxBorder] = currentQuestion.content.range
.split("—")
.map(window.Number);
const min = minBorder < maxBorder ? minBorder : maxBorder;
const max = minBorder < maxBorder ? maxBorder : minBorder;
const reversed = minBorder > maxBorder;
const sendAnswerToBackend = async (value: string, noUpdate = false) => {
try {
await sendAnswer({
questionId: currentQuestion.id,
body: value,
qid: quizId,
});
if (!noUpdate) {
updateAnswer(currentQuestion.id, value, 0);
}
} catch (e) {
enqueueSnackbar("ответ не был засчитан");
}
};
const updateValueDebounced = useDebouncedCallback(async (value: string) => {
if (reversed) {
const newValue =
window.Number(value) < window.Number(min)
? String(min)
: window.Number(value) > window.Number(max)
? String(max)
: value;
setReversedInputValue(newValue);
updateAnswer(
currentQuestion.id,
String(max + min - window.Number(newValue)),
0
);
await sendAnswerToBackend(String(window.Number(newValue)), true);
return;
}
const newValue =
window.Number(value) < window.Number(minRange)
? minRange
: window.Number(value) > window.Number(maxRange)
? maxRange
: value;
setInputValue(newValue);
await sendAnswerToBackend(newValue);
}, 1000);
const updateMinRangeDebounced = useDebouncedCallback(
async (value: string, crowded = false) => {
if (reversed) {
const newMinRange = crowded
? window.Number(value.split("—")[1])
: max + min - window.Number(value.split("—")[0]) < min
? min
: max + min - window.Number(value.split("—")[0]);
const newMinValue =
window.Number(value.split("—")[0]) > max
? String(max)
: value.split("—")[0];
setReversedMinRange(
crowded ? String(max + min - window.Number(newMinValue)) : newMinValue
);
updateAnswer(
currentQuestion.id,
`${newMinRange}${value.split("—")[1]}`,
0
);
await sendAnswerToBackend(
`${newMinValue}${value.split("—")[1]}`,
true
);
return;
}
const newMinValue = crowded
? maxRange
: window.Number(value.split("—")[0]) < min
? String(min)
: value.split("—")[0];
setMinRange(newMinValue);
await sendAnswerToBackend(`${newMinValue}${value.split("—")[1]}`);
},
1000
);
const updateMaxRangeDebounced = useDebouncedCallback(
async (value: string, crowded = false) => {
if (reversed) {
const newMaxRange = crowded
? window.Number(value.split("—")[1])
: max + min - window.Number(value.split("—")[1]) > max
? max
: max + min - window.Number(value.split("—")[1]);
const newMaxValue =
window.Number(value.split("—")[1]) < min
? String(min)
: value.split("—")[1];
setReversedMaxRange(
crowded ? String(max + min - window.Number(newMaxValue)) : newMaxValue
);
updateAnswer(
currentQuestion.id,
`${value.split("—")[0]}${newMaxRange}`,
0
);
await sendAnswerToBackend(
`${value.split("—")[0]}${newMaxValue}`,
true
);
return;
}
const newMaxValue = crowded
? minRange
: window.Number(value.split("—")[1]) > max
? String(max)
: value.split("—")[1];
setMaxRange(newMaxValue);
await sendAnswerToBackend(`${value.split("—")[0]}${newMaxValue}`);
},
1000
);
const answer = answers.find(
({ questionId }) => questionId === currentQuestion.id
)?.answer as string;
const sliderValue =
answer ||
(reversed
? max + min - currentQuestion.content.start + "—" + max
: currentQuestion.content.start + "—" + max);
useEffect(() => {
if (answer) {
if (answer.includes("—")) {
if (reversed) {
setReversedMinRange(
String(max + min - window.Number(answer.split("—")[0]))
);
setReversedMaxRange(
String(max + min - window.Number(answer.split("—")[1]))
);
} else {
setMinRange(answer.split("—")[0]);
setMaxRange(answer.split("—")[1]);
}
} else {
if (reversed) {
setReversedInputValue(String(max + min - window.Number(answer)));
} else {
setInputValue(answer);
}
}
}
if (!answer) {
setMinRange(String(currentQuestion.content.start));
setMaxRange(String(max));
if (currentQuestion.content.chooseRange) {
setReversedMinRange(String(currentQuestion.content.start));
setReversedMaxRange(String(min));
}
setReversedInputValue(String(currentQuestion.content.start));
setInputValue(String(currentQuestion.content.start));
}
}, []);
const onSliderChange = (_: Event, value: number | number[]) => {
const range = Array.isArray(value)
? `${value[0]}${value[1]}`
: String(value);
updateAnswer(currentQuestion.id, range, 0);
};
const onChangeCommitted = async (
_: Event | SyntheticEvent<Element, Event>,
value: number | number[]
) => {
if (currentQuestion.content.chooseRange && Array.isArray(value)) {
if (reversed) {
const newMinReversedValue = String(max + min - value[0]);
const newMaxReversedValue = String(max + min - value[1]);
setMinRange(String(value[0]));
setMaxRange(String(value[1]));
setReversedMinRange(newMinReversedValue);
setReversedMaxRange(newMaxReversedValue);
await sendAnswerToBackend(
`${newMinReversedValue}${newMaxReversedValue}`,
true
);
return;
}
setMinRange(String(value[0]));
setMaxRange(String(value[1]));
await sendAnswerToBackend(`${value[0]}${value[1]}`);
return;
}
if (reversed) {
setReversedInputValue(String(max + min - window.Number(value)));
} else {
setInputValue(String(value));
}
await sendAnswerToBackend(String(value));
};
const changeValueLabelFormat = (value: number) => {
if (!reversed) {
return value;
}
const [minSliderBorder, maxSliderBorder] = sliderValue
.split("—")
.map(window.Number);
if (value === minSliderBorder) {
return max + min - minSliderBorder;
}
return max + min - maxSliderBorder;
};
const onInputChange = ({ target }: ChangeEvent<HTMLInputElement>) => {
const value = target.value.replace(/\D/g, "");
if (reversed) {
setReversedInputValue(value);
} else {
setInputValue(value);
}
updateValueDebounced(value);
};
const onMinInputChange = ({ target }: ChangeEvent<HTMLInputElement>) => {
const newValue = target.value.replace(/\D/g, "");
if (reversed) {
setReversedMinRange(newValue);
if (window.Number(newValue) <= window.Number(reversedMaxRange)) {
const value = max + min - window.Number(reversedMaxRange);
updateMinRangeDebounced(`${value}${value}`, true);
return;
}
updateMinRangeDebounced(
`${newValue}${max + min - window.Number(reversedMaxRange)}`
);
return;
}
setMinRange(newValue);
if (window.Number(newValue) >= window.Number(maxRange)) {
updateMinRangeDebounced(`${maxRange}${maxRange}`, true);
return;
}
updateMinRangeDebounced(`${newValue}${maxRange}`);
};
const onMaxInputChange = ({ target }: ChangeEvent<HTMLInputElement>) => {
const newValue = target.value.replace(/\D/g, "");
if (reversed) {
setReversedMaxRange(newValue);
if (window.Number(newValue) >= window.Number(reversedMinRange)) {
const value = max + min - window.Number(reversedMinRange);
updateMaxRangeDebounced(`${value}${value}`, true);
return;
}
updateMaxRangeDebounced(
`${max + min - window.Number(reversedMinRange)}${newValue}`
);
return;
}
setMaxRange(newValue);
if (window.Number(newValue) <= window.Number(minRange)) {
updateMaxRangeDebounced(`${minRange}${minRange}`, true);
return;
}
updateMaxRangeDebounced(`${minRange}${newValue}`);
};
return (
<Box>
<Typography variant="h5" color={theme.palette.text.primary} sx={{wordBreak: "break-word"}}>
{currentQuestion.title}
</Typography>
<Box
sx={{
display: "flex",
flexDirection: "column",
width: "100%",
marginTop: "20px",
gap: "30px",
paddingRight: isMobile ? "10px" : undefined,
}}
>
<CustomSlider
value={
currentQuestion.content.chooseRange
? sliderValue.split("—").length || 0 > 1
? sliderValue.split("—").map((item) => window.Number(item))
: [min, min + 1]
: window.Number(sliderValue.split("—")[0])
}
min={min}
max={max}
step={currentQuestion.content.step || 1}
onChange={onSliderChange}
onChangeCommitted={onChangeCommitted}
valueLabelFormat={changeValueLabelFormat}
sx={{
color: theme.palette.primary.main,
"& .MuiSlider-valueLabel": {
background: theme.palette.primary.main,
},
}}
/>
{!currentQuestion.content.chooseRange && (
<CustomTextField
placeholder="0"
value={reversed ? reversedInputValue : inputValue}
onChange={onInputChange}
sx={{
maxWidth: "80px",
borderColor: theme.palette.text.primary,
"& .MuiInputBase-input": {
textAlign: "center",
backgroundColor: quizThemes[settings.cfg.theme].isLight
? "white"
: theme.palette.background.default,
},
}}
/>
)}
{currentQuestion.content.chooseRange && (
<Box
sx={{
display: "flex",
gap: "15px",
alignItems: "center",
"& .MuiFormControl-root": { width: "auto" },
}}
>
<CustomTextField
placeholder="0"
value={reversed ? String(reversedMinRange) : minRange}
onChange={onMinInputChange}
sx={{
maxWidth: "80px",
borderColor: theme.palette.text.primary,
"& .MuiInputBase-input": {
textAlign: "center",
backgroundColor: quizThemes[settings.cfg.theme].isLight
? "white"
: theme.palette.background.default,
},
}}
/>
<Typography color={theme.palette.text.primary}>до</Typography>
<CustomTextField
placeholder="0"
value={reversed ? String(reversedMaxRange) : maxRange}
onChange={onMaxInputChange}
sx={{
maxWidth: "80px",
borderColor: theme.palette.text.primary,
"& .MuiInputBase-input": {
textAlign: "center",
backgroundColor: quizThemes[settings.cfg.theme].isLight
? "white"
: theme.palette.background.default,
},
}}
/>
</Box>
)}
</Box>
</Box>
);
};

@ -1,7 +1,5 @@
import { Box, Typography, useTheme } from "@mui/material"; import { Box, Typography, useTheme } from "@mui/material";
import { useQuizViewStore, updateAnswer } from "@stores/quizView/store";
import type { QuizQuestionPage } from "../../../model/questionTypes/page"; import type { QuizQuestionPage } from "../../../model/questionTypes/page";
import YoutubeEmbedIframe from "../tools/YoutubeEmbedIframe"; import YoutubeEmbedIframe from "../tools/YoutubeEmbedIframe";
@ -11,13 +9,11 @@ type PageProps = {
export const Page = ({ currentQuestion }: PageProps) => { export const Page = ({ currentQuestion }: PageProps) => {
const theme = useTheme(); const theme = useTheme();
const { answers } = useQuizViewStore();
const { answer } = answers.find(({ questionId }) => questionId === currentQuestion.id) ?? {};
return ( return (
<Box> <Box>
<Typography variant="h5" sx={{ paddingBottom: "25px", color: theme.palette.text.primary }}>{currentQuestion.title}</Typography> <Typography variant="h5" sx={{ paddingBottom: "25px", color: theme.palette.text.primary, wordBreak: "break-word"}} >{currentQuestion.title}</Typography>
<Typography color={theme.palette.text.primary}>{currentQuestion.content.text}</Typography> <Typography color={theme.palette.text.primary} sx={{wordBreak: "break-word"}}>{currentQuestion.content.text}</Typography>
<Box <Box
sx={{ sx={{
display: "flex", display: "flex",
@ -46,7 +42,7 @@ export const Page = ({ currentQuestion }: PageProps) => {
<YoutubeEmbedIframe <YoutubeEmbedIframe
containerSX={{ containerSX={{
width: "100%", width: "100%",
height: "calc( 100vh - 270px)", height: "calc(100% - 270px)",
maxHeight: "80vh", maxHeight: "80vh",
objectFit: "contain", objectFit: "contain",
}} }}

@ -0,0 +1,143 @@
import {
Box,
Rating as RatingComponent,
Typography,
useTheme
} from "@mui/material";
import { updateAnswer, useQuizViewStore } from "@stores/quizView";
import FlagIcon from "@icons/questionsPage/FlagIcon";
import StarIconMini from "@icons/questionsPage/StarIconMini";
import HashtagIcon from "@icons/questionsPage/hashtagIcon";
import HeartIcon from "@icons/questionsPage/heartIcon";
import LightbulbIcon from "@icons/questionsPage/lightbulbIcon";
import LikeIcon from "@icons/questionsPage/likeIcon";
import TropfyIcon from "@icons/questionsPage/tropfyIcon";
import { sendAnswer } from "@api/quizRelase";
import { enqueueSnackbar } from "notistack";
import { useRootContainerSize } from "../../../contexts/RootContainerWidthContext";
import type { QuizQuestionRating } from "../../../model/questionTypes/rating";
import { useQuizData } from "@contexts/QuizDataContext";
type RatingProps = {
currentQuestion: QuizQuestionRating;
};
const buttonRatingForm = [
{
name: "star",
icon: (color: string) => <StarIconMini width={50} color={color} />,
},
{
name: "trophie",
icon: (color: string) => <TropfyIcon color={color} />,
},
{
name: "flag",
icon: (color: string) => <FlagIcon color={color} />,
},
{
name: "heart",
icon: (color: string) => <HeartIcon color={color} />,
},
{
name: "like",
icon: (color: string) => <LikeIcon color={color} />,
},
{
name: "bubble",
icon: (color: string) => <LightbulbIcon color={color} />,
},
{
name: "hashtag",
icon: (color: string) => <HashtagIcon color={color} />,
},
];
export const Rating = ({ currentQuestion }: RatingProps) => {
const { quizId } = useQuizData();
const { answers } = useQuizViewStore();
const theme = useTheme();
const isMobile = useRootContainerSize() < 650;
const { answer } =
answers.find(
({ questionId }) => questionId === currentQuestion.id
) ?? {};
const form = buttonRatingForm.find(
({ name }) => name === currentQuestion.content.form
);
return (
<Box>
<Typography variant="h5" color={theme.palette.text.primary} sx={{ wordBreak: "break-word" }}>{currentQuestion.title}</Typography>
<Box
sx={{
display: "inline-flex",
alignItems: "center",
gap: "20px",
marginTop: "20px",
flexDirection: "column",
width: isMobile ? "100%" : undefined,
}}
>
<Box
sx={{
display: "inline-block",
width: "100%",
}}
>
<RatingComponent
value={Number(answer || 0)}
onChange={async (_, value) => {
try {
await sendAnswer({
questionId: currentQuestion.id,
body: String(value) + " из " + currentQuestion.content.steps,
qid: quizId,
});
updateAnswer(currentQuestion.id, String(value), 0);
} catch (e) {
enqueueSnackbar("ответ не был засчитан");
}
}}
sx={{
height: "50px",
gap: isMobile ? undefined : "15px",
justifyContent: isMobile ? "space-between" : undefined,
width: isMobile ? "100%" : undefined
}}
max={currentQuestion.content.steps}
icon={form?.icon(theme.palette.primary.main)}
emptyIcon={form?.icon("#9A9AAF")}
/>
</Box>
<Box
sx={{
display: "flex",
justifyContent: "space-between",
gap: 2,
width: "100%",
}}
>
<Typography sx={{
color: "#9A9AAF"
// color: theme.palette.grey2.main
}}>
{currentQuestion.content.ratingNegativeDescription}
</Typography>
<Typography sx={{ color: "#9A9AAF" }}>
{currentQuestion.content.ratingPositiveDescription}
</Typography>
</Box>
</Box>
</Box>
);
};

@ -0,0 +1,78 @@
import { Box, Typography, useTheme } from "@mui/material";
import { Select as SelectComponent } from "../tools//Select";
import { deleteAnswer, updateAnswer, useQuizViewStore } from "@stores/quizView";
import { sendAnswer } from "@api/quizRelase";
import { enqueueSnackbar } from "notistack";
import type { QuizQuestionSelect } from "../../../model/questionTypes/select";
import { useQuizData } from "@contexts/QuizDataContext";
type SelectProps = {
currentQuestion: QuizQuestionSelect;
};
export const Select = ({ currentQuestion }: SelectProps) => {
const theme = useTheme();
const { quizId } = useQuizData();
const { answers } = useQuizViewStore();
const { answer } =
answers.find(
({ questionId }) => questionId === currentQuestion.id
) ?? {};
return (
<Box>
<Typography variant="h5" color={theme.palette.text.primary} sx={{wordBreak: "break-word"}}>{currentQuestion.title}</Typography>
<Box
sx={{
display: "flex",
flexDirection: "column",
width: "100%",
marginTop: "20px",
}}
>
<SelectComponent
placeholder={currentQuestion.content.default}
activeItemIndex={answer ? Number(answer) : -1}
items={currentQuestion.content.variants.map(({ answer }) => answer)}
colorMain={theme.palette.primary.main}
onChange={async (_, value) => {
if (value < 0) {
deleteAnswer(currentQuestion.id);
try {
await sendAnswer({
questionId: currentQuestion.id,
body: "",
qid: quizId,
});
} catch (e) {
enqueueSnackbar("ответ не был засчитан");
}
return;
}
try {
await sendAnswer({
questionId: currentQuestion.id,
body: String(currentQuestion.content.variants[Number(value)].answer),
qid: quizId,
});
updateAnswer(currentQuestion.id, String(value), 0);
} catch (e) {
enqueueSnackbar("ответ не был засчитан");
}
}}
/>
</Box>
</Box>
);
};

@ -0,0 +1,78 @@
import { Box, Typography, useMediaQuery, useTheme } from "@mui/material";
import CustomTextField from "@ui_kit/CustomTextField";
import { updateAnswer, useQuizViewStore } from "@stores/quizView";
import { sendAnswer } from "@api/quizRelase";
import { enqueueSnackbar } from "notistack";
import { useDebouncedCallback } from "use-debounce";
import type { QuizQuestionText } from "../../../model/questionTypes/text";
import { useQuizData } from "@contexts/QuizDataContext";
type TextProps = {
currentQuestion: QuizQuestionText;
};
export const Text = ({ currentQuestion }: TextProps) => {
const theme = useTheme();
const { quizId } = useQuizData();
const { answers } = useQuizViewStore();
const isMobile = useMediaQuery(theme.breakpoints.down(650));
const { answer } = answers.find(({ questionId }) => questionId === currentQuestion.id) ?? {};
const inputHC = useDebouncedCallback(async (text) => {
try {
await sendAnswer({
questionId: currentQuestion.id,
body: text,
qid: quizId,
});
} catch (e) {
enqueueSnackbar("ответ не был засчитан");
}
}, 400);
return (
<Box>
<Typography variant="h5" color={theme.palette.text.primary} sx={{wordBreak: "break-word"}}>{currentQuestion.title}</Typography>
<Box
sx={{
display: "flex",
width: "100%",
marginTop: "20px",
flexDirection: isMobile ? "column-reverse" : undefined,
alignItems: "center"
}}
>
<CustomTextField
placeholder={currentQuestion.content.placeholder}
//@ts-ignore
value={answer || ""}
onChange={async ({ target }) => {
updateAnswer(currentQuestion.id, target.value, 0);
inputHC(target.value);
}
}
sx={{
"&:focus-visible": {
borderColor: theme.palette.primary.main
}
}}
/>
{currentQuestion.content.back && currentQuestion.content.back !== " " && (
<Box sx={{ maxWidth: "400px", width: "100%", height: "300px", margin: "15px" }}>
<img
key={currentQuestion.id}
src={currentQuestion.content.back}
style={{ width: "100%", height: "100%", objectFit: "cover" }}
alt=""
/>
</Box>
)}
</Box>
</Box>
);
};

@ -0,0 +1,279 @@
import {
Box,
Checkbox,
FormControlLabel,
FormGroup,
TextField as MuiTextField,
Radio,
RadioGroup,
TextFieldProps,
Typography,
useTheme
} from "@mui/material";
import { FC, useEffect, useState } from "react";
import {
deleteAnswer,
updateAnswer,
updateOwnVariant,
useQuizViewStore
} from "@stores/quizView";
import { CheckboxIcon } from "@icons/Checkbox";
import RadioCheck from "@ui_kit/RadioCheck";
import RadioIcon from "@ui_kit/RadioIcon";
import { sendAnswer } from "@api/quizRelase";
import { useQuizData } from "@contexts/QuizDataContext";
import { useRootContainerSize } from "@contexts/RootContainerWidthContext";
import { quizThemes } from "@utils/themes/Publication/themePublication";
import { enqueueSnackbar } from "notistack";
import type { QuestionVariant } from "../../../model/questionTypes/shared";
import type { QuizQuestionVariant } from "../../../model/questionTypes/variant";
const TextField = MuiTextField as unknown as FC<TextFieldProps>;
type VariantProps = {
currentQuestion: QuizQuestionVariant;
};
type VariantItemProps = {
currentQuestion: QuizQuestionVariant;
variant: QuestionVariant;
answer: string | string[] | undefined;
index: number;
own?: boolean;
readySend: boolean;
setReadySend: (a: boolean) => void
};
export const Variant = ({ currentQuestion }: VariantProps) => {
const theme = useTheme();
const isMobile = useRootContainerSize() < 650;
const { answers, ownVariants } = useQuizViewStore();
const { answer } =
answers.find(
({ questionId }) => questionId === currentQuestion.id
) ?? {};
const ownVariant = ownVariants.find(
(variant) => variant.id === currentQuestion.id
);
const [readySend, setReadySend] = useState(true)
const Group = currentQuestion.content.multi ? FormGroup : RadioGroup;
useEffect(() => {
if (!ownVariant) {
updateOwnVariant(currentQuestion.id, "");
}
}, []);
return (
<Box>
<Typography variant="h5" color={theme.palette.text.primary} sx={{ wordBreak: "break-word" }}>{currentQuestion.title}</Typography>
<Box sx={{
display: "flex", gap: "20px",
flexDirection: isMobile ? "column-reverse" : undefined, alignItems: isMobile ? "center" : undefined
}}>
<Group
name={currentQuestion.id.toString()}
value={currentQuestion.content.variants.findIndex(
({ id }) => answer === id
)}
sx={{
display: "flex",
flexWrap: "wrap",
flexDirection: "row",
justifyContent: "space-between",
flexBasis: "100%",
marginTop: "20px",
width: isMobile ? "100%" : undefined
}}
>
<Box
sx={{
display: "flex",
flexDirection: "row",
flexWrap: "wrap",
width: "100%",
gap: "20px",
}}
>
{currentQuestion.content.variants.map((variant, index) => (
<VariantItem
key={variant.id}
currentQuestion={currentQuestion}
variant={variant}
// @ts-ignore
answer={answer}
index={index}
readySend={readySend}
setReadySend={setReadySend}
/>
))}
{currentQuestion.content.own && ownVariant && (
<VariantItem
own
currentQuestion={currentQuestion}
variant={ownVariant.variant}
// @ts-ignore
answer={answer}
index={currentQuestion.content.variants.length + 2}
readySend={readySend}
setReadySend={setReadySend}
/>
)}
</Box>
</Group>
{currentQuestion.content.back && currentQuestion.content.back !== " " && (
<Box sx={{ maxWidth: "400px", width: "100%", height: "300px" }}>
<img
key={currentQuestion.id}
src={currentQuestion.content.back}
style={{ width: "100%", height: "100%", objectFit: "cover" }}
alt=""
/>
</Box>
)}
</Box>
</Box>
);
};
const VariantItem = ({
currentQuestion,
variant,
answer,
index,
own = false,
readySend,
setReadySend
}: VariantItemProps) => {
const theme = useTheme();
const { settings, quizId } = useQuizData();
return (
<FormControlLabel
key={variant.id}
sx={{
margin: "0",
borderRadius: "12px",
color: theme.palette.text.primary,
padding: "15px",
border: `1px solid`,
borderColor: answer === variant.id
? theme.palette.primary.main
: "#9A9AAF",
backgroundColor: quizThemes[settings.cfg.theme].isLight
? "white"
: theme.palette.background.default,
display: "flex",
maxWidth: "685px",
maxHeight: "85px",
justifyContent: "space-between",
width: "100%",
"&.MuiFormControl-root": {
width: "100%",
},
"& .MuiFormControlLabel-label": {
wordBreak: "break-word",
height: variant.answer.length <= 60 ? undefined : "60px",
overflow: "auto",
lineHeight: "normal",
"&::-webkit-scrollbar": {
width: "4px",
},
"&::-webkit-scrollbar-thumb": {
backgroundColor: "#b8babf",
}
}
}}
value={index}
labelPlacement="start"
control={
currentQuestion.content.multi ?
<Checkbox
checked={!!answer?.includes(variant.id)}
checkedIcon={<CheckboxIcon checked color={theme.palette.primary.main} />}
icon={<CheckboxIcon />}
/>
:
<Radio checkedIcon={<RadioCheck color={theme.palette.primary.main} />} icon={<RadioIcon />} />
}
label={own ? <TextField label="Другое..." /> : variant.answer}
onClick={async (event) => {
event.preventDefault();
if (readySend) {
setReadySend(false)
const variantId = currentQuestion.content.variants[index].id;
console.log(answer);
if (currentQuestion.content.multi) {
const currentAnswer = typeof answer !== "string" ? answer || [] : [];
try {
await sendAnswer({
questionId: currentQuestion.id,
body: currentAnswer.includes(variantId)
? currentAnswer?.filter((item) => item !== variantId)
: [...currentAnswer, variantId],
qid: quizId,
});
updateAnswer(
currentQuestion.id,
currentAnswer.includes(variantId)
? currentAnswer?.filter((item) => item !== variantId)
: [...currentAnswer, variantId],
currentQuestion.content.variants[index].points || 0
);
} catch (e) {
console.log(e);
enqueueSnackbar("ответ не был засчитан");
}
return;
}
try {
await sendAnswer({
questionId: currentQuestion.id,
body: currentQuestion.content.variants[index].answer,
qid: quizId,
});
updateAnswer(currentQuestion.id, variantId,
answer === variantId ? 0
:
currentQuestion.content.variants[index].points || 0
);
} catch (e) {
console.log(e);
enqueueSnackbar("ответ не был засчитан");
}
if (answer === variantId) {
try {
await sendAnswer({
questionId: currentQuestion.id,
body: "",
qid: quizId,
});
} catch (e) {
console.log(e);
enqueueSnackbar("ответ не был засчитан");
}
deleteAnswer(currentQuestion.id);
}
setReadySend(true)
}
}}
/>
);
};

@ -0,0 +1,189 @@
import {
Box,
FormControlLabel,
Radio,
RadioGroup,
Typography,
useTheme
} from "@mui/material";
import { deleteAnswer, updateAnswer, useQuizViewStore } from "@stores/quizView";
import RadioCheck from "@ui_kit/RadioCheck";
import RadioIcon from "@ui_kit/RadioIcon";
import { sendAnswer } from "@api/quizRelase";
import BlankImage from "@icons/BlankImage";
import { useQuizData } from "@contexts/QuizDataContext";
import { quizThemes } from "@utils/themes/Publication/themePublication";
import { enqueueSnackbar } from "notistack";
import { useRootContainerSize } from "../../../contexts/RootContainerWidthContext";
import type { QuizQuestionVarImg } from "../../../model/questionTypes/varimg";
type VarimgProps = {
currentQuestion: QuizQuestionVarImg;
};
export const Varimg = ({ currentQuestion }: VarimgProps) => {
const { settings, quizId } = useQuizData();
const { answers } = useQuizViewStore();
const theme = useTheme();
const isMobile = useRootContainerSize() < 650;
const { answer } =
answers.find(
({ questionId }) => questionId === currentQuestion.id
) ?? {};
const variant = currentQuestion.content.variants.find(
({ id }) => answer === id
);
return (
<Box>
<Typography variant="h5" color={theme.palette.text.primary} sx={{wordBreak: "break-word"}}>{currentQuestion.title}</Typography>
<Box sx={{
display: "flex",
marginTop: "20px",
flexDirection: isMobile ? "column-reverse" : undefined,
gap: isMobile ? "30px" : undefined,
alignItems: isMobile ? "center" : undefined
}}>
<RadioGroup
name={currentQuestion.id}
value={currentQuestion.content.variants.findIndex(
({ id }) => answer === id
)}
sx={{
display: "flex",
flexWrap: "wrap",
flexDirection: "row",
justifyContent: "space-between",
flexBasis: "100%",
width: isMobile ? "100%" : undefined
}}
>
<Box sx={{ display: "flex", flexDirection: "column", width: "100%", gap: isMobile ? "20px" : undefined }}>
{currentQuestion.content.variants.map((variant, index) => (
<FormControlLabel
key={variant.id}
sx={{
marginBottom: "15px",
borderRadius: "5px",
padding: "15px",
color: theme.palette.text.primary,
backgroundColor: quizThemes[settings.cfg.theme].isLight ? "white" : theme.palette.background.default,
border: `1px solid`,
borderColor: answer === variant.id ? theme.palette.primary.main : "#9A9AAF",
display: "flex",
margin: isMobile ? 0 : undefined,
"& .MuiFormControlLabel-label": {
wordBreak: "break-word",
height: variant.answer.length <= 60 ? undefined : "60px",
overflow: "auto",
lineHeight: "normal",
paddingLeft: "45px",
"&::-webkit-scrollbar": {
width: "4px",
},
"&::-webkit-scrollbar-thumb": {
backgroundColor: "#b8babf",
}
}
}}
value={index}
onClick={async (event) => {
event.preventDefault();
try {
await sendAnswer({
questionId: currentQuestion.id,
body: `${currentQuestion.content.variants[index].answer} <img style="width:100%; max-width:250px; max-height:250px" src="${currentQuestion.content.variants[index].extendedText}"/>`,
qid: quizId,
});
updateAnswer(
currentQuestion.id,
currentQuestion.content.variants[index].id,
currentQuestion.content.variants[index].points || 0
);
} catch (e) {
enqueueSnackbar("ответ не был засчитан");
}
if (answer === currentQuestion.content.variants[index].id) {
try {
await sendAnswer({
questionId: currentQuestion.id,
body: "",
qid: quizId,
});
} catch (e) {
enqueueSnackbar("ответ не был засчитан");
}
deleteAnswer(currentQuestion.id);
}
}}
control={
<Radio checkedIcon={<RadioCheck color={theme.palette.primary.main} />} icon={<RadioIcon />} />
}
label={variant.answer}
/>
))}
</Box>
</RadioGroup>
{/* {(variant?.extendedText || currentQuestion.content.back) && ( */}
<Box
sx={{
maxWidth: "450px",
width: "100%",
height: "450px",
border: "1px solid #9A9AAF",
borderRadius: "12px",
overflow: "hidden",
display: "flex",
alignItems: "center",
justifyContent: "center",
backgroundColor: "#9A9AAF12",
color: "#9A9AAF",
textAlign: "center"
}}
>
{answer ? (
variant?.extendedText ? (
<img
src={variant?.extendedText}
style={{ width: "100%", height: "100%", objectFit: "cover" }}
alt=""
/>
) : (
<BlankImage />
)
) : currentQuestion.content.back !== " "
&& currentQuestion.content.back !== null
&& currentQuestion.content.back.length > 0
? (
<img
src={currentQuestion.content.back}
style={{ width: "100%", height: "100%", objectFit: "cover" }}
alt=""
/>
) : (currentQuestion.content.replText !== " " && currentQuestion.content.replText.length > 0) ? currentQuestion.content.replText : variant?.extendedText || isMobile ? (
"Выберите вариант ответа ниже"
) : (
"Выберите вариант ответа слева"
)}
</Box>
{/* )} */}
</Box>
</Box>
);
};

@ -135,7 +135,8 @@ export const Select = ({
padding: "10px", padding: "10px",
borderRadius: "5px", borderRadius: "5px",
color: colorPlaceholder, color: colorPlaceholder,
whiteSpace: "normal" whiteSpace: "normal",
wordBreak: "break-word"
}} }}
> >
{item} {item}

@ -0,0 +1,16 @@
import { QuizQuestionResult } from "@model/questionTypes/result";
export const isResultQuestionEmpty = (resultQuestion: QuizQuestionResult) => {
if (
(resultQuestion.title.length > 0 && resultQuestion.title !== " ")
|| (resultQuestion.description.length > 0 && resultQuestion.description !== " ")
|| (resultQuestion.content.back.length > 0 && resultQuestion.content.back !== " ")
|| (resultQuestion.content.originalBack.length > 0 && resultQuestion.content.originalBack !== " ")
|| (resultQuestion.content.innerName.length > 0 && resultQuestion.content.innerName !== " ")
|| (resultQuestion.content.text.length > 0 && resultQuestion.content.text !== " ")
|| (resultQuestion.content.video.length > 0 && resultQuestion.content.video !== " ")
|| (resultQuestion.content.hint.text.length > 0 && resultQuestion.content.hint.text !== " ")
) return false;
return true;
};

@ -0,0 +1,69 @@
import { UploadFileType } from "@model/questionTypes/file";
export const MAX_FILE_SIZE = 419430400;
export const UPLOAD_FILE_DESCRIPTIONS_MAP = {
picture: {
title: "Добавить изображение",
description: "Принимает изображения",
},
video: {
title: "Добавить видео",
description: "Принимает .mp4 и .mov формат — максимум 50мб",
},
audio: { title: "Добавить аудиофайл", description: "Принимает аудиофайлы" },
document: { title: "Добавить документ", description: "Принимает документы" },
} as const satisfies Record<UploadFileType, { title: string; description: string; }>;
export const ACCEPT_SEND_FILE_TYPES_MAP = {
picture: [
".jpeg",
".jpg",
".png",
".ico",
".gif",
".tiff",
".webp",
".eps",
".svg"
],
video: [
".mp4",
".mov",
".wmv",
".avi",
".avchd",
".flv",
".f4v",
".swf",
".mkv",
".webm",
".mpeg-2"
],
audio: [
".aac",
".aiff",
".dsd",
".flac",
".mp3",
".mqa",
".ogg",
".wav",
".wma"
],
document: [
".doc",
".docx",
".dotx",
".rtf",
".odt",
".pdf",
".txt",
".xls",
".ppt",
".xlsx",
".pptx",
".pages",
],
} as const;

@ -0,0 +1,13 @@
import { QuizSettings } from "@model/settingsData";
import { createContext, useContext } from "react";
type QuizData = QuizSettings & { quizId: string; preview: boolean; };
export const QuizDataContext = createContext<QuizData | null>(null);
export const useQuizData = () => {
const quizData = useContext(QuizDataContext);
if (quizData === null) throw new Error("QuizData context is null");
return quizData;
};

@ -0,0 +1,11 @@
import { createContext, useContext } from "react";
export const RootContainerWidthContext = createContext<number | null>(null);
export const useRootContainerSize = () => {
const rootContainerSize = useContext(RootContainerWidthContext);
if (rootContainerSize === null) throw new Error("rootContainerSize context is null");
return rootContainerSize;
};

5
lib/index.ts Normal file

@ -0,0 +1,5 @@
import QuizAnswerer from "./components/QuizAnswerer";
import type { QuizSettings } from "@model/settingsData";
export { QuizAnswerer };
export type { QuizSettings };

@ -0,0 +1,54 @@
import { AnyTypedQuizQuestion } from "@model/questionTypes/shared";
import { QuizSettings } from "@model/settingsData";
export interface GetQuizDataResponse {
cnt: number;
settings: {
fp: boolean;
rep: boolean;
name: string;
cfg: string;
lim: number;
due: number;
delay: number;
pausable: boolean;
};
items: {
id: number;
title: string;
desc: string;
typ: string;
req: boolean;
p: number;
c: string;
}[];
}
export function parseQuizData(quizDataResponse: GetQuizDataResponse): Omit<QuizSettings, "recentlyCompleted"> {
const items: QuizSettings["questions"] = quizDataResponse.items.map((item) => {
const content = JSON.parse(item.c);
return {
description: item.desc,
id: item.id,
page: item.p,
required: item.req,
title: item.title,
type: item.typ,
content
} as unknown as AnyTypedQuizQuestion;
});
const settings: QuizSettings["settings"] = {
fp: quizDataResponse.settings.fp,
rep: quizDataResponse.settings.rep,
name: quizDataResponse.settings.name,
cfg: JSON.parse(quizDataResponse?.settings.cfg),
lim: quizDataResponse.settings.lim,
due: quizDataResponse.settings.due,
delay: quizDataResponse.settings.delay,
pausable: quizDataResponse.settings.pausable
};
return { cnt: quizDataResponse.cnt, settings, questions: items };
}

@ -7,6 +7,7 @@ import type {
export interface QuizQuestionDate extends QuizQuestionBase { export interface QuizQuestionDate extends QuizQuestionBase {
type: "date"; type: "date";
content: { content: {
id: string;
/** Чекбокс "Необязательный вопрос" */ /** Чекбокс "Необязательный вопрос" */
required: boolean; required: boolean;
/** Чекбокс "Внутреннее название вопроса" */ /** Чекбокс "Внутреннее название вопроса" */

@ -8,6 +8,7 @@ import type {
export interface QuizQuestionEmoji extends QuizQuestionBase { export interface QuizQuestionEmoji extends QuizQuestionBase {
type: "emoji"; type: "emoji";
content: { content: {
id: string;
/** Чекбокс "Можно несколько" */ /** Чекбокс "Можно несколько" */
multi: boolean; multi: boolean;
/** Чекбокс "Вариант "свой ответ"" */ /** Чекбокс "Вариант "свой ответ"" */

@ -16,6 +16,7 @@ export type UploadFileType = keyof typeof UPLOAD_FILE_TYPES_MAP;
export interface QuizQuestionFile extends QuizQuestionBase { export interface QuizQuestionFile extends QuizQuestionBase {
type: "file"; type: "file";
content: { content: {
id: string;
/** Чекбокс "Необязательный вопрос" */ /** Чекбокс "Необязательный вопрос" */
required: boolean; required: boolean;
/** Чекбокс "Внутреннее название вопроса" */ /** Чекбокс "Внутреннее название вопроса" */

@ -8,6 +8,7 @@ import type {
export interface QuizQuestionImages extends QuizQuestionBase { export interface QuizQuestionImages extends QuizQuestionBase {
type: "images"; type: "images";
content: { content: {
id: string;
/** Чекбокс "Вариант "свой ответ"" */ /** Чекбокс "Вариант "свой ответ"" */
own: boolean; own: boolean;
/** Чекбокс "Можно несколько" */ /** Чекбокс "Можно несколько" */
@ -28,8 +29,8 @@ export interface QuizQuestionImages extends QuizQuestionBase {
variants: QuestionVariant[]; variants: QuestionVariant[];
hint: QuestionHint; hint: QuestionHint;
rule: QuestionBranchingRule; rule: QuestionBranchingRule;
back: string; back: string | null;
originalBack: string; originalBack: string | null;
autofill: boolean; autofill: boolean;
largeCheck: boolean; largeCheck: boolean;
}; };

@ -7,6 +7,7 @@ import type {
export interface QuizQuestionNumber extends QuizQuestionBase { export interface QuizQuestionNumber extends QuizQuestionBase {
type: "number"; type: "number";
content: { content: {
id: string;
/** Чекбокс "Необязательный вопрос" */ /** Чекбокс "Необязательный вопрос" */
required: boolean; required: boolean;
/** Чекбокс "Внутреннее название вопроса" */ /** Чекбокс "Внутреннее название вопроса" */

@ -7,6 +7,7 @@ import type {
export interface QuizQuestionPage extends QuizQuestionBase { export interface QuizQuestionPage extends QuizQuestionBase {
type: "page"; type: "page";
content: { content: {
id: string;
/** Чекбокс "Внутреннее название вопроса" */ /** Чекбокс "Внутреннее название вопроса" */
innerNameCheck: boolean; innerNameCheck: boolean;
/** Поле "Внутреннее название вопроса" */ /** Поле "Внутреннее название вопроса" */

@ -7,6 +7,7 @@ import type {
export interface QuizQuestionRating extends QuizQuestionBase { export interface QuizQuestionRating extends QuizQuestionBase {
type: "rating"; type: "rating";
content: { content: {
id: string;
/** Чекбокс "Необязательный вопрос" */ /** Чекбокс "Необязательный вопрос" */
required: boolean; required: boolean;
/** Чекбокс "Внутреннее название вопроса" */ /** Чекбокс "Внутреннее название вопроса" */

@ -4,9 +4,13 @@ import type {
QuestionHint, QuestionHint,
} from "./shared"; } from "./shared";
interface ResultQuestionBranchingRule extends QuestionBranchingRule {
minScore?: number
}
export interface QuizQuestionResult extends QuizQuestionBase { export interface QuizQuestionResult extends QuizQuestionBase {
type: "result"; type: "result";
content: { content: {
id: string;
back: string; back: string;
originalBack: string; originalBack: string;
video: string; video: string;
@ -14,7 +18,7 @@ export interface QuizQuestionResult extends QuizQuestionBase {
text: string; text: string;
price: [number] | [number, number]; price: [number] | [number, number];
useImage: boolean; useImage: boolean;
rule: QuestionBranchingRule, rule: ResultQuestionBranchingRule,
hint: QuestionHint; hint: QuestionHint;
autofill: boolean; autofill: boolean;
redirect: string redirect: string

@ -8,6 +8,7 @@ import type {
export interface QuizQuestionSelect extends QuizQuestionBase { export interface QuizQuestionSelect extends QuizQuestionBase {
type: "select"; type: "select";
content: { content: {
id: string;
/** Чекбокс "Можно несколько" */ /** Чекбокс "Можно несколько" */
multi: boolean; multi: boolean;
/** Чекбокс "Необязательный вопрос" */ /** Чекбокс "Необязательный вопрос" */

@ -1,3 +1,4 @@
import { nanoid } from "nanoid";
import type { QuizQuestionDate } from "./date"; import type { QuizQuestionDate } from "./date";
import type { QuizQuestionEmoji } from "./emoji"; import type { QuizQuestionEmoji } from "./emoji";
import type { QuizQuestionFile } from "./file"; import type { QuizQuestionFile } from "./file";
@ -5,24 +6,23 @@ import type { QuizQuestionImages } from "./images";
import type { QuizQuestionNumber } from "./number"; import type { QuizQuestionNumber } from "./number";
import type { QuizQuestionPage } from "./page"; import type { QuizQuestionPage } from "./page";
import type { QuizQuestionRating } from "./rating"; import type { QuizQuestionRating } from "./rating";
import type { QuizQuestionResult } from "./result";
import type { QuizQuestionSelect } from "./select"; import type { QuizQuestionSelect } from "./select";
import type { QuizQuestionText } from "./text"; import type { QuizQuestionText } from "./text";
import type { QuizQuestionVariant } from "./variant"; import type { QuizQuestionVariant } from "./variant";
import type { QuizQuestionVarImg } from "./varimg"; import type { QuizQuestionVarImg } from "./varimg";
import type { QuizQuestionResult } from "./result";
import { nanoid } from "nanoid";
export interface QuestionBranchingRuleMain { export interface QuestionBranchingRuleMain {
next: string; next: string;
or: boolean; or: boolean;
rules: { rules: {
question: string; //id родителя (пока что) question: string; //id родителя (пока что)
answers: string[] answers: string[];
}[] }[];
} }
export interface QuestionBranchingRule {
children: string[], export interface QuestionBranchingRule {
children: string[];
//список условий //список условий
main: QuestionBranchingRuleMain[]; main: QuestionBranchingRuleMain[];
parentId: string | null | "root"; parentId: string | null | "root";
@ -46,6 +46,7 @@ export type QuestionVariant = {
extendedText: string; extendedText: string;
/** Оригинал изображения (до кропа) */ /** Оригинал изображения (до кропа) */
originalImageUrl: string; originalImageUrl: string;
points?: number;
}; };
export type QuestionType = export type QuestionType =
@ -76,15 +77,15 @@ export interface QuizQuestionBase {
required: boolean; required: boolean;
deleteTimeoutId: number; deleteTimeoutId: number;
content: { content: {
id: string;
hint: QuestionHint; hint: QuestionHint;
rule: QuestionBranchingRule; rule: QuestionBranchingRule;
back: string; back: string | null;
originalBack: string; originalBack: string | null;
autofill: boolean; autofill: boolean;
}; };
} }
export type AnyTypedQuizQuestion = export type AnyTypedQuizQuestion =
| QuizQuestionVariant | QuizQuestionVariant
| QuizQuestionImages | QuizQuestionImages
@ -99,7 +100,7 @@ export type AnyTypedQuizQuestion =
| QuizQuestionRating | QuizQuestionRating
| QuizQuestionResult; | QuizQuestionResult;
export type RealTypedQuizQuestion = Exclude<AnyTypedQuizQuestion, QuizQuestionResult>;
type FilterQuestionsWithVariants<T> = T extends { type FilterQuestionsWithVariants<T> = T extends {
content: { variants: QuestionVariant[]; }; content: { variants: QuestionVariant[]; };
@ -108,18 +109,19 @@ type FilterQuestionsWithVariants<T> = T extends {
export type QuizQuestionsWithVariants = FilterQuestionsWithVariants<AnyTypedQuizQuestion>; export type QuizQuestionsWithVariants = FilterQuestionsWithVariants<AnyTypedQuizQuestion>;
export const createBranchingRuleMain: (targetId:string, parentId:string) => QuestionBranchingRuleMain = (targetId, parentId) => ({ export const createBranchingRuleMain: (targetId: string, parentId: string) => QuestionBranchingRuleMain = (targetId, parentId) => ({
next: targetId, next: targetId,
or: false, or: false,
rules: [{ rules: [{
question: parentId, question: parentId,
answers: [] as string[], answers: [] as string[],
}] }]
}) });
export const createQuestionVariant: () => QuestionVariant = () => ({ export const createQuestionVariant: () => QuestionVariant = () => ({
id: nanoid(), id: nanoid(),
answer: "", answer: "",
extendedText: "", extendedText: "",
hints: "", hints: "",
originalImageUrl: "", originalImageUrl: "",
}); });

@ -7,7 +7,7 @@ import type {
export interface QuizQuestionText extends QuizQuestionBase { export interface QuizQuestionText extends QuizQuestionBase {
type: "text"; type: "text";
content: { content: {
id: number; id: string;
placeholder: string; placeholder: string;
/** Чекбокс "Внутреннее название вопроса" */ /** Чекбокс "Внутреннее название вопроса" */
innerNameCheck: boolean; innerNameCheck: boolean;

@ -8,6 +8,7 @@ import type {
export interface QuizQuestionVariant extends QuizQuestionBase { export interface QuizQuestionVariant extends QuizQuestionBase {
type: "variant"; type: "variant";
content: { content: {
id: string;
/** Чекбокс "Длинный текстовый ответ" */ /** Чекбокс "Длинный текстовый ответ" */
largeCheck: boolean; largeCheck: boolean;
/** Чекбокс "Можно несколько" */ /** Чекбокс "Можно несколько" */

@ -8,6 +8,7 @@ import type {
export interface QuizQuestionVarImg extends QuizQuestionBase { export interface QuizQuestionVarImg extends QuizQuestionBase {
type: "varimg"; type: "varimg";
content: { content: {
id: string;
/** Чекбокс "Вариант "свой ответ"" */ /** Чекбокс "Вариант "свой ответ"" */
own: boolean; own: boolean;
/** Чекбокс "Внутреннее название вопроса" */ /** Чекбокс "Внутреннее название вопроса" */

123
lib/model/settingsData.ts Normal file

@ -0,0 +1,123 @@
import { AnyTypedQuizQuestion } from "./questionTypes/shared";
export type QuizStartpageType = "standard" | "expanded" | "centered" | null;
export type QuizStartpageAlignType = "left" | "right" | "center";
export type QuizType = "quiz" | "form" | null;
export type QuizResultsType = true | null;
export type QuizStep = "startpage" | "question" | "contactform";
export type QuizTheme =
| "StandardTheme"
| "StandardDarkTheme"
| "PinkTheme"
| "PinkDarkTheme"
| "BlackWhiteTheme"
| "OliveTheme"
| "YellowTheme"
| "GoldDarkTheme"
| "PurpleTheme"
| "BlueTheme"
| "BlueDarkTheme";
export type FCField = {
text: string;
innerText: string;
key: string;
required: boolean;
used: boolean;
};
export type QuizSettings = {
questions: AnyTypedQuizQuestion[];
settings: {
fp: boolean;
rep: boolean;
name: string;
lim: number;
due: number;
delay: number;
pausable: boolean;
cfg: QuizConfig;
};
cnt: number;
recentlyCompleted: boolean;
};
export interface QuizConfig {
type: QuizType;
noStartPage: boolean;
startpageType: QuizStartpageType;
score?: boolean;
results: QuizResultsType;
haveRoot: string | null;
theme: QuizTheme;
resultInfo: {
when: "email" | "";
share: boolean;
replay: boolean;
theme: string;
reply: string;
replname: string;
showResultForm: "before" | "after";
};
startpage: {
description: string;
button: string;
position: QuizStartpageAlignType;
favIcon: string | null;
logo: string | null;
originalLogo: string | null;
background: {
type: null | "image" | "video";
desktop: string | null;
originalDesktop: string | null;
mobile: string | null;
originalMobile: string | null;
video: string | null;
cycle: boolean;
};
};
formContact: {
title: string;
desc: string;
fields: Record<FormContactFieldName, FormContactFieldData>;
button: string;
};
info: {
phonenumber: string;
clickable: boolean;
orgname: string;
site: string;
law?: string;
};
meta: string;
}
export type FormContactFieldName =
| "name"
| "email"
| "phone"
| "text"
| "address";
type FormContactFieldData = {
text: string;
innerText: string;
key: string;
required: boolean;
used: boolean;
};
export interface QuizItems {
description: string;
id: number;
page: number;
required: boolean;
title: string;
type: string;
content: unknown;
}

123
lib/stores/quizView.ts Normal file

@ -0,0 +1,123 @@
import { QuestionVariant } from "@model/questionTypes/shared";
import { produce } from "immer";
import { nanoid } from "nanoid";
import { create } from "zustand";
import { devtools } from "zustand/middleware";
import type { Moment } from "moment";
import { QuizStep } from "@model/settingsData";
type QuestionAnswer = {
questionId: string;
answer: string | string[] | Moment;
};
type OwnVariant = {
id: string;
variant: QuestionVariant;
};
interface QuizViewStore {
answers: QuestionAnswer[];
ownVariants: OwnVariant[];
pointsSum: number;
points: Record<string, number>;
currentQuizStep: QuizStep;
}
export const useQuizViewStore = create<QuizViewStore>()(
devtools(
(set, get) => ({
answers: [],
ownVariants: [],
points: {},
pointsSum: 0,
currentQuizStep: "startpage",
}),
{
name: "quizView",
enabled: import.meta.env.DEV,
trace: import.meta.env.DEV,
}
)
);
function setProducedState<A extends string | { type: string; }>(
recipe: (state: QuizViewStore) => void,
action?: A,
) {
useQuizViewStore.setState(state => produce(state, recipe), false, action);
}
const calcPoints = () => {
const storePoints = useQuizViewStore.getState().points;
let sum = Object.values(storePoints).reduce((accumulator, currentValue) => accumulator + currentValue)
console.log("сумма ", sum)
useQuizViewStore.setState({ pointsSum: sum })
}
export const updateAnswer = (
questionId: string,
answer: string | string[] | Moment,
points: number
) => {
setProducedState(state => {
const index = state.answers.findIndex(answer => questionId === answer.questionId);
if (index < 0) {
state.answers.push({ questionId, answer });
} else {
state.answers[index] = { questionId, answer };
}
}, {
type: "updateAnswer",
questionId,
answer
})
const storePoints = useQuizViewStore.getState().points;
useQuizViewStore.setState({ points: { ...storePoints, ...{ [questionId]: points } } })
calcPoints()
};
export const deleteAnswer = (questionId: string) => useQuizViewStore.setState(state => ({
answers: state.answers.filter(answer => questionId !== answer.questionId)
}), false, {
type: "deleteAnswer",
questionId
});
export const updateOwnVariant = (id: string, answer: string) => setProducedState(state => {
const index = state.ownVariants.findIndex((variant) => variant.id === id);
if (index < 0) {
state.ownVariants.push({
id,
variant: {
id: nanoid(),
answer,
extendedText: "",
hints: "",
originalImageUrl: "",
},
});
} else {
state.ownVariants[index].variant.answer = answer;
}
}, {
type: "updateOwnVariant",
id,
answer
});
export const deleteOwnVariant = (id: string) => useQuizViewStore.setState(state => ({
ownVariants: state.ownVariants.filter((variant) => variant.id !== id)
}), false, {
type: "deleteOwnVariant",
id
});
export const setCurrentQuizStep = (currentQuizStep: QuizStep) => useQuizViewStore.setState({
currentQuizStep
}, false, {
type: "setCurrentQuizStep",
currentQuizStep
});

@ -1,4 +1,4 @@
import { FormControlLabel, Checkbox, useTheme, Box, useMediaQuery } from "@mui/material"; import { Checkbox, FormControlLabel } from "@mui/material";
import React from "react"; import React from "react";
import { CheckboxIcon } from "@icons/Checkbox"; import { CheckboxIcon } from "@icons/Checkbox";
@ -15,8 +15,6 @@ interface Props {
} }
export default function CustomCheckbox({ label, handleChange, checked, sx, dataCy, colorIcon }: Props) { export default function CustomCheckbox({ label, handleChange, checked, sx, dataCy, colorIcon }: Props) {
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down(790));
return ( return (
<FormControlLabel <FormControlLabel
@ -25,7 +23,6 @@ export default function CustomCheckbox({ label, handleChange, checked, sx, dataC
sx={{ padding: "0px 13px 1px 11px" }} sx={{ padding: "0px 13px 1px 11px" }}
disableRipple disableRipple
icon={<CheckboxIcon />} icon={<CheckboxIcon />}
//@ts-ignore
checkedIcon={<CheckboxIcon checked color={colorIcon} />} checkedIcon={<CheckboxIcon checked color={colorIcon} />}
onChange={handleChange} onChange={handleChange}
checked={checked} checked={checked}

@ -1,5 +1,7 @@
import { Slider, SxProps, Theme, useTheme } from "@mui/material"; import { Slider, SxProps, Theme, useTheme } from "@mui/material";
import type { ReactNode } from "react";
type CustomSliderProps = { type CustomSliderProps = {
defaultValue?: number; defaultValue?: number;
value?: number | number[]; value?: number | number[];
@ -8,7 +10,11 @@ type CustomSliderProps = {
step?: number; step?: number;
sx?: SxProps<Theme>; sx?: SxProps<Theme>;
onChange?: (_: Event, value: number | number[]) => void; onChange?: (_: Event, value: number | number[]) => void;
onChangeCommitted?: (_: React.SyntheticEvent | Event, value: number | number[]) => void; onChangeCommitted?: (
_: React.SyntheticEvent | Event,
value: number | number[]
) => void;
valueLabelFormat?: (value: number, index: number) => ReactNode;
}; };
export const CustomSlider = ({ export const CustomSlider = ({
@ -19,6 +25,7 @@ export const CustomSlider = ({
step, step,
onChange, onChange,
onChangeCommitted, onChangeCommitted,
valueLabelFormat,
sx, sx,
}: CustomSliderProps) => { }: CustomSliderProps) => {
// const handleChange = ({ type }: Event, newValue: number | number[]) => { // const handleChange = ({ type }: Event, newValue: number | number[]) => {
@ -39,6 +46,7 @@ export const CustomSlider = ({
onChange={onChange} onChange={onChange}
valueLabelDisplay="on" valueLabelDisplay="on"
onChangeCommitted={onChangeCommitted} onChangeCommitted={onChangeCommitted}
valueLabelFormat={valueLabelFormat}
onMouseDown={(e) => e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()}
data-cy="slider" data-cy="slider"
sx={{ sx={{
@ -73,7 +81,7 @@ export const CustomSlider = ({
"& .MuiSlider-track": { "& .MuiSlider-track": {
height: "12px", height: "12px",
}, },
...sx ...sx,
}} }}
/> />
); );

@ -0,0 +1,71 @@
import { FormControl, TextField as MuiTextField, SxProps, Theme, useTheme } from "@mui/material";
import type { InputProps, TextFieldProps } from "@mui/material";
import type { ChangeEvent, FC, FocusEvent, KeyboardEvent } from "react";
const TextField = MuiTextField as unknown as FC<TextFieldProps>; // temporary fix ts(2590)
interface CustomTextFieldProps {
placeholder: string;
value?: string;
error?: string;
onChange?: (event: ChangeEvent<HTMLInputElement>) => void;
onKeyDown?: (event: KeyboardEvent<HTMLInputElement>) => void;
onBlur?: (event: FocusEvent<HTMLInputElement>) => void;
text?: string;
sx?: SxProps<Theme>;
InputProps?: Partial<InputProps>;
}
export default function CustomTextField({
placeholder,
value,
text,
sx,
error,
onChange,
onKeyDown,
onBlur,
InputProps,
}: CustomTextFieldProps) {
const theme = useTheme();
return (
<FormControl fullWidth variant="standard" sx={{ p: 0 }}>
<TextField
defaultValue={text}
fullWidth
value={value}
placeholder={placeholder}
error={!!error}
label={error}
onChange={onChange}
onKeyDown={onKeyDown}
onBlur={onBlur}
sx={{
"& .MuiInputBase-root": {
backgroundColor: theme.palette.background.default,
height: "48px",
borderRadius: "10px",
},
"& .MuiInputLabel-root": {
fontSize: "13.5px",
marginTop: "3px",
},
...sx,
}}
InputProps={InputProps}
inputProps={{
sx: {
borderRadius: "10px",
fontSize: "18px",
lineHeight: "21px",
py: 0,
},
}}
data-cy="textfield"
/>
</FormControl>
);
}

@ -0,0 +1,70 @@
import CalendarIcon from "@icons/CalendarIcon";
import { Box, SxProps, Theme, Typography, useTheme } from "@mui/material";
import { DatePicker } from "@mui/x-date-pickers";
import moment from "moment";
import { useRootContainerSize } from "../contexts/RootContainerWidthContext";
interface Props {
label?: string;
sx?: SxProps<Theme>;
value?: moment.Moment;
onChange?: (value: string | null) => void;
}
export default function LabeledDatePicker({ label, value = moment(), onChange, sx }: Props) {
const theme = useTheme();
const upLg = useRootContainerSize() > theme.breakpoints.values.md;
return (
<Box
sx={{
...sx,
}}
>
{label && (
<Typography
sx={{
fontWeight: 500,
fontSize: "16px",
lineHeight: "20px",
color: "#4D4D4D",
mb: "10px",
}}
>
{label}
</Typography>
)}
<DatePicker
//@ts-ignore
value={value._d}
onChange={onChange}
slots={{
openPickerIcon: () => <CalendarIcon />,
}}
slotProps={{
openPickerButton: {
sx: {
p: 0,
},
"data-cy": "open-datepicker",
},
}}
sx={{
"& .MuiInputBase-root": {
backgroundColor: "#F2F3F7",
borderRadius: "10px",
pr: "22px",
"& input": {
py: "11px",
pl: upLg ? "20px" : "13px",
lineHeight: "19px",
},
"& fieldset": {
borderColor: "#9A9AAF",
},
},
}}
/>
</Box>
);
}

@ -0,0 +1,17 @@
import { Skeleton } from "@mui/material";
export default function LoadingSkeleton() {
return (
<Skeleton
component="div"
variant="rectangular"
sx={{
bgcolor: "grey",
width: "100%",
height: "100%",
}}
/>
);
}

@ -0,0 +1,43 @@
import { ErrorInfo } from "react";
interface ComponentError {
timestamp: number;
message: string;
callStack: string | undefined;
componentStack: string | null | undefined;
}
export function handleComponentError(error: Error, info: ErrorInfo) {
const componentError: ComponentError = {
timestamp: Math.floor(Date.now() / 1000),
message: error.message,
callStack: error.stack,
componentStack: info.componentStack,
};
queueErrorRequest(componentError);
}
let errorsQueue: ComponentError[] = [];
let timeoutId: ReturnType<typeof setTimeout>;
function queueErrorRequest(error: ComponentError) {
errorsQueue.push(error);
clearTimeout(timeoutId);
timeoutId = setTimeout(() => {
sendErrorsToServer();
}, 1000);
}
async function sendErrorsToServer() {
// makeRequest({
// url: "",
// method: "POST",
// body: errorsQueue,
// useToken: true,
// });
console.log(`Fake-sending ${errorsQueue.length} errors to server`, errorsQueue);
errorsQueue = [];
}

@ -0,0 +1,205 @@
import { AnyTypedQuizQuestion } from "@model/questionTypes/shared";
import { setCurrentQuizStep, useQuizViewStore } from "@stores/quizView";
import { useCallback, useDebugValue, useMemo, useState } from "react";
import { isResultQuestionEmpty } from "../../components/ViewPublicationPage/tools/checkEmptyData";
import moment from "moment";
import { useQuizData } from "@contexts/QuizDataContext";
import { enqueueSnackbar } from "notistack";
export function useQuestionFlowControl() {
const { settings, questions } = useQuizData();
const [currentQuestion, setCurrentQuestion] = useState<AnyTypedQuizQuestion>(getFirstQuestion);
const { answers, pointsSum } = useQuizViewStore(state => state);
const linearQuestionIndex = questions.every(({ content }) => content.rule.parentId !== "root") // null when branching enabled
? questions.indexOf(currentQuestion)
: null;
function getFirstQuestion() {
if (questions.length === 0) throw new Error("No questions found");
if (settings.cfg.haveRoot) {
const nextQuestion = questions.find(
question => question.id === settings.cfg.haveRoot || question.content.id === settings.cfg.haveRoot
);
if (!nextQuestion) throw new Error("Root question not found");
return nextQuestion;
}
return questions[0];
}
const nextQuestionIdPointsLogic = () => {
return questions.find(question =>
question.type === "result" && question.content.rule.parentId === "line"
);
};
const nextQuestionIdMainLogic = () => {
const questionAnswer = answers.find(({ questionId }) => questionId === currentQuestion.id);
//Если ответ существует и не является объектом момента
if (questionAnswer && !moment.isMoment(questionAnswer.answer)) {
//Ответы должны храниться в массиве
const userAnswers = Array.isArray(questionAnswer.answer) ? questionAnswer.answer : [questionAnswer.answer];
//Сравниваем список условий ветвления и выбираем подходящее
for (const branchingRule of currentQuestion.content.rule.main) {
if (userAnswers.some(answer => branchingRule.rules[0].answers.includes(answer))) {
return branchingRule.next;
}
}
}
if (!currentQuestion.required) {//вопрос не обязателен и не нашли совпадений между ответами и условиями ветвления
const defaultNextQuestionId = currentQuestion.content.rule.default;
if (defaultNextQuestionId.length > 1 && defaultNextQuestionId !== " ") return defaultNextQuestionId;
//Вопросы типа страница, ползунок, своё поле для ввода и дата не могут иметь больше 1 ребёнка. Пользователь не может настроить там дефолт
//Кинуть на ребёнка надо даже если там нет дефолта
if (
["date", "page", "text", "number"].includes(currentQuestion.type)
&& currentQuestion.content.rule.children.length === 1
) return currentQuestion.content.rule.children[0];
}
//ничё не нашли, ищем резулт
return questions.find(q => {
return q.type === "result" && q.content.rule.parentId === currentQuestion.content.id;
})?.id;
};
const calculateNextQuestionId = useMemo(() => {
if (Boolean(settings.cfg.score)) {
return nextQuestionIdPointsLogic();
}
return nextQuestionIdMainLogic();
}, [answers, currentQuestion, questions]);
const prevQuestion = linearQuestionIndex !== null
? questions[linearQuestionIndex - 1]
: questions.find(q =>
q.id === currentQuestion.content.rule.parentId
|| q.content.id === currentQuestion.content.rule.parentId
);
const findResultPointsLogic = () => {
const results = questions
.filter(e => e.type === "result" && e.content.rule.minScore !== undefined && e.content.rule.minScore <= pointsSum);
const numbers = results.map(e => e.type === "result" && e.content.rule.minScore !== undefined ? e.content.rule.minScore : 0);
const indexOfNext = Math.max(...numbers);
console.log(results);
console.log(numbers);
console.log(indexOfNext);
return results[numbers.indexOf(indexOfNext)];
};
const getNextQuestion = () => {
let next;
console.log(11111111111);
//Искать можно двумя логиками. Основной и балловой
if (Boolean(settings.cfg.score)) {
//Балловая
console.log(222222222);
//Ищем линейно
if (linearQuestionIndex !== null) {
console.log(33333333333);
next = questions[linearQuestionIndex + 1];
console.log(4444444);
console.log("перед ифом", next);
if (next?.type === "result" || next == undefined) next = findResultPointsLogic();
console.log(5555555555);
}
} else {
console.log(6666666);
//Основная
if (linearQuestionIndex !== null) {
console.log(777777777);
//Ищем линейно
next = questions[linearQuestionIndex + 1] ?? questions.find(question =>
question.type === "result" && question.content.rule.parentId === "line"
);
} else {
console.log(88888888888888);
//Ищем ветвлением
next = questions.find(q => q.id === calculateNextQuestionId || q.content.id === calculateNextQuestionId);
}
}
console.log("next", next);
if (!next && currentQuestion.type !== "result") throw new Error("Не найден следующий вопрос");
return next;
};
const nextQuestion = getNextQuestion();
const showResult = useCallback(() => {
if (nextQuestion?.type !== "result") throw new Error("Current question is not result");
setCurrentQuestion(nextQuestion);
if (
settings.cfg.resultInfo.showResultForm === "after"
|| isResultQuestionEmpty(nextQuestion)
) setCurrentQuizStep("contactform");
}, [nextQuestion, settings.cfg.resultInfo.showResultForm]);
const showResultAfterContactForm = useCallback(() => {
if (currentQuestion.type !== "result") throw new Error("Current question is not result");
if (isResultQuestionEmpty(currentQuestion)) {
enqueueSnackbar("Данные отправлены");
return;
}
setCurrentQuizStep("question");
}, [currentQuestion]);
const moveToPrevQuestion = useCallback(() => {
if (!prevQuestion) throw new Error("Previous question not found");
setCurrentQuestion(prevQuestion);
}, [prevQuestion]);
const moveToNextQuestion = useCallback(() => {
if (!nextQuestion) throw new Error("Next question not found");
if (nextQuestion.type === "result") return showResult();
setCurrentQuestion(nextQuestion);
}, [nextQuestion, showResult]);
const isPreviousButtonEnabled = Boolean(prevQuestion);
const isNextButtonEnabled = useMemo(() => {
const hasAnswer = answers.some(({ questionId }) => questionId === currentQuestion.id);
if ("required" in currentQuestion.content && currentQuestion.content.required) {
return hasAnswer;
}
return Boolean(nextQuestion);
}, [answers, currentQuestion.content, currentQuestion.id, nextQuestion]);
useDebugValue({
linearQuestionIndex,
currentQuestion: currentQuestion,
prevQuestion: prevQuestion,
nextQuestion: nextQuestion,
});
return {
currentQuestion,
currentQuestionStepNumber: linearQuestionIndex === null ? null : linearQuestionIndex + 1,
isNextButtonEnabled,
isPreviousButtonEnabled,
moveToPrevQuestion,
moveToNextQuestion,
showResultAfterContactForm,
};
}

@ -1,7 +1,6 @@
import { createTheme } from "@mui/material"; import { QuizTheme } from "@model/settingsData";
import { Theme, createTheme } from "@mui/material";
import themePublic from "./genericPublication"; import themePublic from "./genericPublication";
import theme from "../generic";
const StandardTheme = createTheme({ const StandardTheme = createTheme({
@ -224,30 +223,16 @@ const BlueDarkTheme = createTheme({
} }
}) })
export const modes = { export const quizThemes: Record<QuizTheme, { theme: Theme; isLight: boolean; }> = {
StandardTheme: true, StandardTheme: { theme: StandardTheme, isLight: true },
StandardDarkTheme: false, StandardDarkTheme: { theme: StandardDarkTheme, isLight: false },
PinkTheme: true, PinkTheme: { theme: PinkTheme, isLight: true },
PinkDarkTheme: false, PinkDarkTheme: { theme: PinkDarkTheme, isLight: false },
BlackWhiteTheme: true, BlackWhiteTheme: { theme: BlackWhiteTheme, isLight: true },
OliveTheme: true, OliveTheme: { theme: OliveTheme, isLight: true },
YellowTheme: true, YellowTheme: { theme: YellowTheme, isLight: true },
GoldDarkTheme: false, GoldDarkTheme: { theme: GoldDarkTheme, isLight: false },
PurpleTheme: true, PurpleTheme: { theme: PurpleTheme, isLight: true },
BlueTheme: true, BlueTheme: { theme: BlueTheme, isLight: true },
BlueDarkTheme: false BlueDarkTheme: { theme: BlueDarkTheme, isLight: false },
} };
export const themesPublication = {
StandardTheme,
StandardDarkTheme,
PinkTheme,
PinkDarkTheme,
BlackWhiteTheme,
OliveTheme,
YellowTheme,
GoldDarkTheme,
PurpleTheme,
BlueTheme,
BlueDarkTheme,
}

0
src/utils/themes/dark.ts → lib/utils/themes/dark.ts Executable file → Normal file

@ -89,6 +89,7 @@ const theme = createTheme({
fontWeight: 500, fontWeight: 500,
}, },
fontFamily: [ fontFamily: [
"Twemoji Country Flags",
"Rubik", "Rubik",
"-apple-system", "-apple-system",
"BlinkMacSystemFont", "BlinkMacSystemFont",
@ -134,4 +135,4 @@ theme.typography.infographic = {
} }
}; };
export default theme; export default theme;

@ -1,24 +1,75 @@
{ {
"name": "squzanswerer", "name": "@frontend/squzanswerer",
"version": "0.1.0", "version": "1.0.5",
"private": true,
"type": "module", "type": "module",
"main": "./dist-package/index.js",
"module": "./dist-package/index.js",
"types": "./dist-package/index.d.ts",
"license": "MIT",
"files": [
"dist-package"
],
"exports": {
".": {
"import": "./dist-package/index.js"
}
},
"publishConfig": {
"registry": "https://penahub.gitlab.yandexcloud.net/api/v4/projects/43/packages/npm/"
},
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "tsc && vite build", "build": "tsc && vite build",
"build:widget": "tsc && vite build --config vite.config.widget.ts", "build:widget": "tsc && vite build --config vite.config.widget.ts",
"build:package": "tsc && vite build --config vite.config.package.ts",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview", "preview": "vite preview",
"cypress:open": "cypress open" "cypress:open": "cypress open",
"prepublishOnly": "npm run build:package"
}, },
"dependencies": { "devDependencies": {
"@emoji-mart/data": "^1.1.2",
"@emoji-mart/react": "^1.1.1",
"@emotion/react": "^11.10.5", "@emotion/react": "^11.10.5",
"@emotion/styled": "^11.10.5", "@emotion/styled": "^11.10.5",
"@frontend/kitui": "^1.0.54",
"@mui/icons-material": "^5.10.14", "@mui/icons-material": "^5.10.14",
"@mui/material": "^5.10.14", "@mui/material": "^5.10.14",
"@mui/x-date-pickers": "^6.16.1", "@mui/x-date-pickers": "^6.16.1",
"@types/node": "^16.7.13", "@types/node": "^16.7.13",
"@types/react": "^18.2.43",
"@types/react-dom": "^18.2.17",
"@typescript-eslint/eslint-plugin": "^6.14.0",
"@typescript-eslint/parser": "^6.14.0",
"@vitejs/plugin-react": "^4.2.1",
"axios": "^1.5.1",
"cypress": "^13.6.1",
"emoji-mart": "^5.5.2",
"eslint": "^8.55.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.5",
"immer": "^10.0.3",
"moment": "^2.30.1",
"nanoid": "^5.0.3",
"notistack": "^3.0.1",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-error-boundary": "^4.0.12",
"react-router-dom": "^6.21.3",
"swr": "^2.2.4",
"typescript": "^5.2.2",
"use-debounce": "^9.0.4",
"vite": "^5.0.8",
"vite-plugin-dts": "^3.7.2",
"zustand": "^4.3.8"
},
"peerDependencies": {
"@emoji-mart/data": "^1.1.2",
"@emoji-mart/react": "^1.1.1",
"@emotion/react": "^11.10.5",
"@emotion/styled": "^11.10.5",
"@mui/icons-material": "^5.10.14",
"@mui/material": "^5.10.14",
"@mui/x-date-pickers": "^6.16.1",
"axios": "^1.5.1", "axios": "^1.5.1",
"emoji-mart": "^5.5.2", "emoji-mart": "^5.5.2",
"immer": "^10.0.3", "immer": "^10.0.3",
@ -27,25 +78,13 @@
"notistack": "^3.0.1", "notistack": "^3.0.1",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-router-dom": "^6.6.2", "react-error-boundary": "^4.0.12",
"react-router-dom": "^6.21.3",
"swr": "^2.2.4", "swr": "^2.2.4",
"typescript": "^5.2.2",
"use-debounce": "^9.0.4", "use-debounce": "^9.0.4",
"zustand": "^4.3.8" "zustand": "^4.3.8"
}, },
"devDependencies": { "dependencies": {
"@emoji-mart/data": "^1.1.2", "country-flag-emoji-polyfill": "^0.1.8"
"@emoji-mart/react": "^1.1.1",
"@types/react": "^18.2.43",
"@types/react-dom": "^18.2.17",
"@typescript-eslint/eslint-plugin": "^6.14.0",
"@typescript-eslint/parser": "^6.14.0",
"@vitejs/plugin-react": "^4.2.1",
"cypress": "^13.6.1",
"eslint": "^8.55.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.5",
"typescript": "^5.2.2",
"vite": "^5.0.8"
} }
} }

223
pub.js

File diff suppressed because one or more lines are too long

@ -1,38 +1,36 @@
import { CssBaseline, ThemeProvider } from "@mui/material"; import { getQuizData } from "@api/quizRelase";
import { LocalizationProvider } from "@mui/x-date-pickers"; import { Box } from "@mui/material";
import { AdapterMoment } from "@mui/x-date-pickers/AdapterMoment"; import LoadingSkeleton from "@ui_kit/LoadingSkeleton";
import { ruRU } from '@mui/x-date-pickers/locales'; import { useParams } from "react-router-dom";
import moment from "moment"; import useSWR from "swr";
import { SnackbarProvider } from 'notistack'; import QuizAnswerer from "../lib/components/QuizAnswerer";
import { BrowserRouter } from "react-router-dom"; import { ApologyPage } from "../lib/components/ViewPublicationPage/ApologyPage";
import { SWRConfig } from "swr";
import { ViewPage } from "./pages/ViewPublicationPage";
import lightTheme from "./utils/themes/light";
// const defaultQuizId = "45ef7f9c-784d-4e58-badb-f6b337f08ba0"; // branching
moment.locale("ru"); const defaultQuizId = "cde381db-8ccb-402c-b55f-2c814be9bf25"; //looooong header
const localeText = ruRU.components.MuiLocalizationProvider.defaultProps.localeText; // const defaultQuizId = "ad7f5a87-b833-4f5b-854e-453706ed655c"; // linear
export default function App() { export default function App() {
const quizId = useParams().quizId ?? defaultQuizId;
const { data, error, isLoading } = useSWR(["quizData", quizId], params => getQuizData(params[1]), {
revalidateOnFocus: false,
revalidateOnReconnect: false,
shouldRetryOnError: false,
refreshInterval: 0,
});
if (isLoading) return <LoadingSkeleton />;
if (error) return <ApologyPage error={error} />;
if (!data) throw new Error("Quiz data is null");
return ( return (
<SWRConfig value={{ <Box sx={{
revalidateOnFocus: false, height: "100dvh",
shouldRetryOnError: false,
}}> }}>
<LocalizationProvider dateAdapter={AdapterMoment} adapterLocale="ru" localeText={localeText}> <QuizAnswerer
<ThemeProvider theme={lightTheme}> quizSettings={data}
<BrowserRouter> quizId={quizId}
<SnackbarProvider />
preventDuplicate={true} </Box>
style={{ backgroundColor: lightTheme.palette.brightPurple.main }}
>
<CssBaseline />
<ViewPage />
</SnackbarProvider>
</BrowserRouter>
</ThemeProvider>
</LocalizationProvider>
</SWRConfig>
); );
} }

30
src/WidgetApp.tsx Normal file

@ -0,0 +1,30 @@
import { getQuizData } from "@api/quizRelase";
import LoadingSkeleton from "@ui_kit/LoadingSkeleton";
import useSWR from "swr";
import QuizAnswerer from "../lib/components/QuizAnswerer";
import { ApologyPage } from "../lib/components/ViewPublicationPage/ApologyPage";
interface Props {
quizId: string;
}
export default function WidgetApp({ quizId }: Props) {
const { data, error, isLoading } = useSWR(["quizData", quizId], params => getQuizData(params[1]), {
revalidateOnFocus: false,
revalidateOnReconnect: false,
shouldRetryOnError: false,
refreshInterval: 0,
});
if (isLoading) return <LoadingSkeleton />;
if (error) return <ApologyPage error={error} />;
if (!data) throw new Error("Quiz data is null");
return (
<QuizAnswerer
quizSettings={data}
quizId={quizId}
/>
);
}

@ -1,148 +0,0 @@
import axios from "axios";
import type { AxiosError } from "axios";
import type { GetDataResponse } from "../model/settingsData";
let SESSIONS = "";
export const publicationMakeRequest = ({ url, body }: any) => {
console.log(body);
return axios(url, {
//@ts-ignore
data: body,
headers: {
"X-Sessionkey": SESSIONS,
"Content-Type": "multipart/form-data",
},
method: "POST",
});
};
export async function getData(quizId: string): Promise<{
data: GetDataResponse | null;
isRecentlyCompleted: boolean;
error?: string;
}> {
const QID =
process.env.NODE_ENV === "production"
? window.location.pathname.replace(/\//g, "")
: "ef836ff8-35b1-4031-9acf-af5766bac2b2";
try {
const { data, headers } = await axios<GetDataResponse>(
`/answer/settings`,
{
method: "POST",
// headers,
data: JSON.stringify({
quiz_id: quizId,
limit: 100,
page: 0,
need_config: true,
}),
}
);
const sessions = JSON.parse(localStorage.getItem("sessions") || "{}");
if (typeof sessions[QID] === "number") {
// unix время. Если меньше суток прошло - выводить ошибку, иначе пустить дальше
if (Date.now() - sessions[QID] < 86400000) {
return { data, isRecentlyCompleted: true };
}
}
SESSIONS = headers["x-sessionkey"];
return { data, isRecentlyCompleted: false };
} catch (nativeError) {
const error = nativeError as AxiosError;
return { data: null, isRecentlyCompleted: false, error: error.message };
}
}
export function sendAnswer({ questionId, body, qid }: any) {
const formData = new FormData();
console.log(qid);
const answers = [
{
question_id: questionId,
content: body, //тут массив с ответом
},
];
formData.append("answers", JSON.stringify(answers));
formData.append("qid", qid);
return publicationMakeRequest({
url: `/answer/answer`,
body: formData,
method: "POST",
});
}
//body ={file, filename}
export function sendFile({ questionId, body, qid }: any) {
console.log(body);
const formData = new FormData();
const answers: any = [
{
question_id: questionId,
content: "file:" + body.name,
},
];
formData.append("answers", JSON.stringify(answers));
formData.append(body.name, body.file);
formData.append("qid", qid);
return publicationMakeRequest({
url: `/answer/answer`,
body: formData,
method: "POST",
});
}
const fields = [
"name",
"email",
"phone",
"adress",
"telegram",
"wechat",
"viber",
"vk",
"skype",
"whatsup",
"messenger",
"text",
];
//форма контактов
export function sendFC({ questionId, body, qid }: any) {
const formData = new FormData();
// const keysBody = Object.keys(body)
// const content:any = {}
// fields.forEach((key) => {
// if (keysBody.includes(key)) content[key] = body.key
// })
const answers = [
{
question_id: questionId,
content: JSON.stringify(body),
result: true,
qid,
},
];
formData.append("answers", JSON.stringify(answers));
formData.append("qid", qid);
return publicationMakeRequest({
url: `/answer/answer`,
body: formData,
method: "POST",
});
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

@ -1,8 +0,0 @@
<svg width="204" height="134" viewBox="0 0 204 134" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="1.25" y="1.25" width="201.5" height="131.5" rx="6.75" stroke="#7E2AEA" strokeWidth="1.5"/>
<rect x="7.5" y="62.5" width="49" height="64" rx="3.5" fill="#7E2AEA" stroke="#7E2AEA"/>
<path d="M202 14.75H202.75V14V8C202.75 4.27208 199.728 1.25 196 1.25H8C4.27208 1.25 1.25 4.27208 1.25 8V14V14.75H2H202Z" fill="#F2F3F7" stroke="#7E2AEA" strokeWidth="1.5"/>
<circle cx="169.5" cy="8" r="2.5" fill="#7E2AEA"/>
<circle cx="177.5" cy="8" r="2.5" fill="#7E2AEA"/>
<circle cx="185.5" cy="8" r="2.5" fill="#7E2AEA"/>
</svg>

Before

Width:  |  Height:  |  Size: 622 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

@ -1,81 +0,0 @@
import { Box } from "@mui/material";
interface Props {
color?: string;
}
export default function CalendarIcon({ color }: Props) {
return (
<Box
sx={{
height: "36px",
width: "36px",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<svg
width="36"
height="36"
viewBox="0 0 36 36"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<g id="Group 22">
<rect id="Border" width="36" height="36" rx="6" fill="#7E2AEA" />
<g id="Group 21">
<path
id="Vector"
d="M25.5 9.75H10.5C10.0858 9.75 9.75 10.0858 9.75 10.5V25.5C9.75 25.9142 10.0858 26.25 10.5 26.25H25.5C25.9142 26.25 26.25 25.9142 26.25 25.5V10.5C26.25 10.0858 25.9142 9.75 25.5 9.75Z"
stroke="white"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
id="Vector_2"
d="M22.5 8.25V11.25"
stroke="white"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
id="Vector_3"
d="M13.5 8.25V11.25"
stroke="white"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
id="Vector_4"
d="M9.75 14.25H26.25"
stroke="white"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
id="Vector_5"
d="M14.625 18H17.25L15.75 19.875C15.9969 19.8746 16.24 19.9351 16.4579 20.0512C16.6757 20.1672 16.8616 20.3353 16.999 20.5404C17.1363 20.7455 17.2209 20.9814 17.2453 21.2271C17.2696 21.4727 17.2329 21.7206 17.1385 21.9487C17.0441 22.1768 16.8949 22.378 16.704 22.5346C16.5132 22.6912 16.2866 22.7983 16.0445 22.8463C15.8024 22.8944 15.5521 22.8819 15.3159 22.81C15.0798 22.7382 14.865 22.6091 14.6906 22.4344"
stroke="white"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
id="Vector_6"
d="M19.5 19.125L21 18V22.875"
stroke="white"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</g>
</g>
</svg>
</Box>
);
}

@ -1,535 +0,0 @@
import { Box } from "@mui/material";
interface Props {
color?: string;
}
export default function Notebook({ color }: Props) {
return (
<Box
sx={{
height: "171px",
width: "279px",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<svg
width="279"
height="171"
viewBox="0 0 279 171"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M123.717 1.39258V3.94582L126.959 3.94582C127.348 3.94582 127.663 4.26075 127.663 4.64924V6.96329C127.663 7.60426 128.182 8.12386 128.823 8.12386L150.642 8.12386C151.283 8.12386 151.802 7.60426 151.802 6.96329V4.64924C151.802 4.26075 152.117 3.94582 152.506 3.94582L155.284 3.94582V1.39258L123.717 1.39258Z"
fill="black"
/>
<circle
cx="139.409"
cy="4.55077"
r="1.06935"
transform="rotate(-180 139.409 4.55077)"
fill="url(#paint0_linear_3_590)"
/>
<circle
cx="139.385"
cy="4.52608"
r="0.580283"
fill="url(#paint1_radial_3_590)"
/>
<g filter="url(#filter0_f_3_590)">
<path
d="M139.383 4.17799C139.291 4.17799 139.202 4.2269 139.137 4.31396C139.072 4.40102 139.035 4.5191 139.035 4.64222C139.035 4.76534 139.072 4.88342 139.137 4.97048C139.202 5.05754 139.291 5.10645 139.383 5.10645L139.383 4.64222L139.383 4.17799Z"
fill="url(#paint2_linear_3_590)"
/>
</g>
<g filter="url(#filter1_f_3_590)">
<circle cx="139.384" cy="4.29403" r="0.116057" fill="#50326D" />
</g>
<g filter="url(#filter2_f_3_590)">
<path
d="M139.501 5.10643C139.624 5.10643 139.742 5.05752 139.829 4.97046C139.916 4.8834 139.965 4.76533 139.965 4.6422C139.965 4.51908 139.916 4.40101 139.829 4.31395C139.742 4.22689 139.624 4.17798 139.501 4.17798L139.501 4.6422L139.501 5.10643Z"
fill="url(#paint3_linear_3_590)"
/>
</g>
<g filter="url(#filter3_f_3_590)">
<ellipse
cx="139.5"
cy="4.75815"
rx="0.232113"
ry="0.116057"
fill="url(#paint4_linear_3_590)"
/>
</g>
<path
d="M117.914 159.462H139.501V162.711H121.164C119.369 162.711 117.914 161.257 117.914 159.462Z"
fill="url(#paint5_radial_3_590)"
/>
<path
d="M117.914 159.462H139.501V162.711H121.164C119.369 162.711 117.914 161.257 117.914 159.462Z"
fill="url(#paint6_linear_3_590)"
/>
<path
d="M161.088 159.462H139.501V162.712H157.838C159.633 162.712 161.088 161.257 161.088 159.462Z"
fill="url(#paint7_radial_3_590)"
/>
<path
d="M161.088 159.462H139.501V162.712H157.838C159.633 162.712 161.088 161.257 161.088 159.462Z"
fill="url(#paint8_linear_3_590)"
/>
<path
d="M249.523 169.443L248.826 168.747H264.958L264.262 169.443H249.523Z"
fill="url(#paint9_linear_3_590)"
/>
<rect
x="249.521"
y="169.443"
width="14.8552"
height="0.232113"
rx="0.116057"
fill="#2A2A2A"
/>
<path
d="M249.754 169.675H264.145L263.813 170.165C263.725 170.294 263.58 170.371 263.424 170.371H250.474C250.319 170.371 250.173 170.294 250.086 170.165L249.754 169.675Z"
fill="url(#paint10_linear_3_590)"
/>
<path
d="M14.6241 169.443L13.9277 168.747H30.0596L29.3633 169.443H14.6241Z"
fill="url(#paint11_linear_3_590)"
/>
<rect
x="14.625"
y="169.443"
width="14.6231"
height="0.232113"
rx="0.116057"
fill="#2A2A2A"
/>
<path
d="M14.8555 169.675H29.0144L28.6894 170.162C28.6024 170.293 28.456 170.371 28.2992 170.371H15.5707C15.4139 170.371 15.2675 170.293 15.1805 170.162L14.8555 169.675Z"
fill="url(#paint12_linear_3_590)"
/>
<path
d="M21.9376 6.79972C21.9376 3.23858 24.8245 0.35171 28.3857 0.35171L250.613 0.35171C254.174 0.35171 257.061 3.23858 257.061 6.79973V159.11L21.9376 159.11L21.9376 6.79972Z"
stroke="url(#paint13_linear_3_590)"
strokeWidth="0.70342"
/>
<path
d="M22.633 6.79261C22.633 3.61996 25.2049 1.048 28.3776 1.048L250.619 1.048C253.792 1.048 256.364 3.61994 256.364 6.7926V159.11L22.633 159.11L22.633 6.79261Z"
fill="black"
stroke="#2D2E31"
strokeWidth="0.70342"
/>
<rect
x="22.9805"
y="153.891"
width="233.042"
height="5.57072"
fill="url(#paint14_linear_3_590)"
/>
<g filter="url(#filter4_iii_3_590)">
<path
d="M0 159.462L279 159.462V164.057C279 166.647 276.9 168.746 274.311 168.746L4.68946 168.746C2.09954 168.746 0 166.647 0 164.057L0 159.462Z"
fill="#D1D2D4"
/>
<path
d="M0 159.462L279 159.462V164.057C279 166.647 276.9 168.746 274.311 168.746L4.68946 168.746C2.09954 168.746 0 166.647 0 164.057L0 159.462Z"
fill="url(#paint15_linear_3_590)"
/>
</g>
<mask
id="mask0_3_590"
// style="mask-type:alpha"
maskUnits="userSpaceOnUse"
x="26"
y="10"
width="227"
height="144"
>
<rect
x="26.0039"
y="10.0527"
width="226.184"
height="143.839"
fill="#D9D9D9"
/>
</mask>
<g mask="url(#mask0_3_590)">
<rect
x="26.0039"
y="10.0525"
width="226.184"
height="143.839"
fill="#F0F1F6"
/>
</g>
<defs>
<filter
id="filter0_f_3_590"
x="138.709"
y="3.85232"
width="0.998971"
height="1.57978"
filterUnits="userSpaceOnUse"
colorInterpolationFilters="sRGB"
>
<feFlood floodOpacity="0" result="BackgroundImageFix" />
<feBlend
mode="normal"
in="SourceGraphic"
in2="BackgroundImageFix"
result="shape"
/>
<feGaussianBlur
stdDeviation="0.162829"
result="effect1_foregroundBlur_3_590"
/>
</filter>
<filter
id="filter1_f_3_590"
x="138.125"
y="3.0356"
width="2.51719"
height="2.51694"
filterUnits="userSpaceOnUse"
colorInterpolationFilters="sRGB"
>
<feFlood floodOpacity="0" result="BackgroundImageFix" />
<feBlend
mode="normal"
in="SourceGraphic"
in2="BackgroundImageFix"
result="shape"
/>
<feGaussianBlur
stdDeviation="0.571191"
result="effect1_foregroundBlur_3_590"
/>
</filter>
<filter
id="filter2_f_3_590"
x="139.174"
y="3.85232"
width="1.11616"
height="1.57978"
filterUnits="userSpaceOnUse"
colorInterpolationFilters="sRGB"
>
<feFlood floodOpacity="0" result="BackgroundImageFix" />
<feBlend
mode="normal"
in="SourceGraphic"
in2="BackgroundImageFix"
result="shape"
/>
<feGaussianBlur
stdDeviation="0.162829"
result="effect1_foregroundBlur_3_590"
/>
</filter>
<filter
id="filter3_f_3_590"
x="138.887"
y="4.2613"
width="1.22643"
height="0.993766"
filterUnits="userSpaceOnUse"
colorInterpolationFilters="sRGB"
>
<feFlood floodOpacity="0" result="BackgroundImageFix" />
<feBlend
mode="normal"
in="SourceGraphic"
in2="BackgroundImageFix"
result="shape"
/>
<feGaussianBlur
stdDeviation="0.190397"
result="effect1_foregroundBlur_3_590"
/>
</filter>
<filter
id="filter4_iii_3_590"
x="0"
y="156.648"
width="279"
height="12.0981"
filterUnits="userSpaceOnUse"
colorInterpolationFilters="sRGB"
>
<feFlood floodOpacity="0" result="BackgroundImageFix" />
<feBlend
mode="normal"
in="SourceGraphic"
in2="BackgroundImageFix"
result="shape"
/>
<feColorMatrix
in="SourceAlpha"
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
result="hardAlpha"
/>
<feOffset dy="-3.98605" />
<feGaussianBlur stdDeviation="1.40684" />
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1" />
<feColorMatrix
type="matrix"
values="0 0 0 0 0.502066 0 0 0 0 0.502766 0 0 0 0 0.504167 0 0 0 1 0"
/>
<feBlend
mode="normal"
in2="shape"
result="effect1_innerShadow_3_590"
/>
<feColorMatrix
in="SourceAlpha"
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
result="hardAlpha"
/>
<feOffset dy="-1.40684" />
<feGaussianBlur stdDeviation="0.586183" />
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1" />
<feColorMatrix
type="matrix"
values="0 0 0 0 0.900764 0 0 0 0 0.904549 0 0 0 0 0.908333 0 0 0 1 0"
/>
<feBlend
mode="normal"
in2="effect1_innerShadow_3_590"
result="effect2_innerShadow_3_590"
/>
<feColorMatrix
in="SourceAlpha"
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
result="hardAlpha"
/>
<feOffset dy="-0.234473" />
<feGaussianBlur stdDeviation="0.35171" />
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1" />
<feColorMatrix
type="matrix"
values="0 0 0 0 0.616667 0 0 0 0 0.616667 0 0 0 0 0.616667 0 0 0 1 0"
/>
<feBlend
mode="normal"
in2="effect2_innerShadow_3_590"
result="effect3_innerShadow_3_590"
/>
</filter>
<linearGradient
id="paint0_linear_3_590"
x1="138.529"
y1="3.98464"
x2="140.227"
y2="4.99109"
gradientUnits="userSpaceOnUse"
>
<stop stop-color="#222222" />
<stop offset="1" stop-color="#0B0B0B" />
</linearGradient>
<radialGradient
id="paint1_radial_3_590"
cx="0"
cy="0"
r="1"
gradientUnits="userSpaceOnUse"
gradientTransform="translate(139.385 4.47333) rotate(37.875) scale(0.601477 0.605921)"
>
<stop stop-color="#152457" />
<stop offset="1" />
</radialGradient>
<linearGradient
id="paint2_linear_3_590"
x1="139.122"
y1="4.28115"
x2="139.417"
y2="5.07777"
gradientUnits="userSpaceOnUse"
>
<stop stop-color="#20569B" />
<stop offset="0.677083" stop-color="#061127" />
</linearGradient>
<linearGradient
id="paint3_linear_3_590"
x1="139.849"
y1="5.00327"
x2="139.615"
y2="4.16228"
gradientUnits="userSpaceOnUse"
>
<stop stop-color="#3D6495" />
<stop offset="0.71875" stop-color="#061127" />
</linearGradient>
<linearGradient
id="paint4_linear_3_590"
x1="139.809"
y1="4.83552"
x2="139.7"
y2="4.56205"
gradientUnits="userSpaceOnUse"
>
<stop stop-color="#3291AF" />
<stop offset="1" stop-color="#3291AF" stop-opacity="0" />
</linearGradient>
<radialGradient
id="paint5_radial_3_590"
cx="0"
cy="0"
r="1"
gradientUnits="userSpaceOnUse"
gradientTransform="translate(128.707 159.462) rotate(90) scale(4.52621 30.0669)"
>
<stop stop-color="white" />
<stop offset="1" stop-color="#D9D9D9" />
</radialGradient>
<linearGradient
id="paint6_linear_3_590"
x1="118.32"
y1="161.551"
x2="122.556"
y2="159.462"
gradientUnits="userSpaceOnUse"
>
<stop offset="0.00209588" stop-color="#242424" />
<stop offset="0.34936" stop-color="#EFEFEF" />
</linearGradient>
<radialGradient
id="paint7_radial_3_590"
cx="0"
cy="0"
r="1"
gradientUnits="userSpaceOnUse"
gradientTransform="translate(150.295 159.462) rotate(90) scale(4.52621 30.0669)"
>
<stop stop-color="white" />
<stop offset="1" stop-color="#D9D9D9" />
</radialGradient>
<linearGradient
id="paint8_linear_3_590"
x1="160.682"
y1="161.551"
x2="156.446"
y2="159.462"
gradientUnits="userSpaceOnUse"
>
<stop offset="0.00209588" stop-color="#242424" />
<stop offset="0.34936" stop-color="#EFEFEF" />
</linearGradient>
<linearGradient
id="paint9_linear_3_590"
x1="248.826"
y1="168.863"
x2="264.842"
y2="168.863"
gradientUnits="userSpaceOnUse"
>
<stop stop-color="#D1D2D4" />
<stop offset="0.063237" stop-color="#818181" />
<stop offset="0.507008" stop-color="#D0D0D0" />
<stop offset="0.864583" stop-color="#818181" />
<stop offset="1" stop-color="#D1D2D4" />
</linearGradient>
<linearGradient
id="paint10_linear_3_590"
x1="249.99"
y1="169.907"
x2="264.145"
y2="169.907"
gradientUnits="userSpaceOnUse"
>
<stop stop-color="#4D4D4D" />
<stop offset="0.156699" stop-color="#292929" />
<stop offset="0.501829" stop-color="#6A6A6A" />
<stop offset="0.884758" stop-color="#2E2D2D" />
<stop offset="1" stop-color="#4D4D4D" />
</linearGradient>
<linearGradient
id="paint11_linear_3_590"
x1="13.9277"
y1="168.863"
x2="29.9435"
y2="168.863"
gradientUnits="userSpaceOnUse"
>
<stop stop-color="#D1D2D4" />
<stop offset="0.063237" stop-color="#818181" />
<stop offset="0.507008" stop-color="#D0D0D0" />
<stop offset="0.864583" stop-color="#818181" />
<stop offset="1" stop-color="#D1D2D4" />
</linearGradient>
<linearGradient
id="paint12_linear_3_590"
x1="15.0876"
y1="169.907"
x2="29.0144"
y2="169.907"
gradientUnits="userSpaceOnUse"
>
<stop stop-color="#4D4D4D" />
<stop offset="0.156699" stop-color="#292929" />
<stop offset="0.501829" stop-color="#6A6A6A" />
<stop offset="0.884758" stop-color="#2E2D2D" />
<stop offset="1" stop-color="#4D4D4D" />
</linearGradient>
<linearGradient
id="paint13_linear_3_590"
x1="16.9437"
y1="-2.17555e-06"
x2="77.177"
y2="59.8852"
gradientUnits="userSpaceOnUse"
>
<stop stop-color="#A8A8A8" />
<stop offset="1" stop-color="#737475" />
</linearGradient>
<linearGradient
id="paint14_linear_3_590"
x1="139.501"
y1="153.891"
x2="139.501"
y2="159.462"
gradientUnits="userSpaceOnUse"
>
<stop stop-color="#2D2D2D" />
<stop offset="1" />
</linearGradient>
<linearGradient
id="paint15_linear_3_590"
x1="0"
y1="164.104"
x2="279"
y2="164.104"
gradientUnits="userSpaceOnUse"
>
<stop stop-color="#A9AAAC" />
<stop
offset="0.0205416"
stop-color="#F2F2F2"
stop-opacity="0.921875"
/>
<stop offset="0.0339099" stop-color="#787879" />
<stop
offset="0.124814"
stop-color="#D6D6D6"
stop-opacity="0.606575"
/>
<stop offset="0.515625" stop-color="#E4E4E4" stop-opacity="0" />
<stop
offset="0.864583"
stop-color="#D7D7D7"
stop-opacity="0.666378"
/>
<stop offset="0.973923" stop-color="#848484" />
<stop offset="0.992314" stop-color="#F4F4F4" />
<stop offset="1" stop-color="#BFBFBF" />
</linearGradient>
</defs>
</svg>
</Box>
);
}

Some files were not shown because too many files have changed in this diff Show More