diff --git a/package.json b/package.json index 878d4638..b12ec2c0 100755 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ "react-rnd": "^10.4.1", "react-router-dom": "^6.6.2", "react-scripts": "5.0.1", + "swr": "^2.2.4", "typescript": "^4.4.2", "use-debounce": "^9.0.4", "web-vitals": "^2.1.0", diff --git a/src/api/quiz.ts b/src/api/quiz.ts index 718cd407..e4b121d0 100644 --- a/src/api/quiz.ts +++ b/src/api/quiz.ts @@ -5,40 +5,44 @@ import { DeleteQuizRequest, DeleteQuizResponse } from "model/quiz/delete"; import { EditQuizRequest, EditQuizResponse } from "model/quiz/edit"; import { GetQuizRequest, GetQuizResponse } from "model/quiz/get"; import { GetQuizListRequest, GetQuizListResponse } from "model/quiz/getList"; -import { BackendQuiz } from "model/quiz/quiz"; +import { RawQuiz } from "model/quiz/quiz"; const baseUrl = process.env.NODE_ENV === "production" ? "/squiz" : "https://squiz.pena.digital/squiz"; -function createQuiz(body: CreateQuizRequest = defaultCreateQuizBody) { - return makeRequest({ +function createQuiz(body?: Partial) { + return makeRequest({ url: `${baseUrl}/quiz/create`, - body, + body: { ...defaultCreateQuizBody, ...body }, method: "POST", }); } -function getQuizList(body: GetQuizListRequest = defaultGetQuizListBody) { - return makeRequest({ +async function getQuizList(body?: Partial) { + const response = await makeRequest({ url: `${baseUrl}/quiz/getList`, - body, - method: "GET", + body: { ...defaultGetQuizListBody, ...body }, + method: "POST", }); + + return response.items; } -function getQuiz(body: GetQuizRequest = defaultGetQuizBody) { +function getQuiz(body?: Partial) { return makeRequest({ url: `${baseUrl}/quiz/get`, - body, + body: { ...defaultGetQuizBody, ...body }, method: "GET", }); } -function editQuiz(body: EditQuizRequest = defaultEditQuizBody) { +async function editQuiz(body: EditQuizRequest, signal?: AbortSignal) { + // await new Promise((resolve) => setTimeout(resolve, 1000)); return makeRequest({ url: `${baseUrl}/quiz/edit`, body, method: "PATCH", + signal, }); } @@ -86,18 +90,18 @@ const defaultCreateQuizBody: CreateQuizRequest = { "fingerprinting": true, "repeatable": true, "note_prevented": true, - "mail_notifications": true, + "mail_notifications": false, "unique_answers": true, "name": "string", "description": "string", "config": "string", - "status": "string", + "status": "stop", "limit": 0, "due_to": 0, "time_of_passing": 0, - "pausable": true, + "pausable": false, "question_cnt": 0, - "super": true, + "super": false, "group_id": 0, }; @@ -129,14 +133,6 @@ const defaultGetQuizBody: GetQuizRequest = { }; const defaultGetQuizListBody: GetQuizListRequest = { - "limit": 0, + "limit": 100, "offset": 0, - "from": 0, - "to": 0, - "search": "string", - "status": "string", - "deleted": true, - "archived": true, - "super": true, - "group_id": 0, }; diff --git a/src/index.tsx b/src/index.tsx index d51bdc3b..fb7258fe 100755 --- a/src/index.tsx +++ b/src/index.tsx @@ -11,6 +11,7 @@ import { HTML5Backend } from "react-dnd-html5-backend"; import { createRoot } from "react-dom/client"; import "./index.css"; import lightTheme from "./utils/themes/light"; +import { SWRConfig } from "swr"; dayjs.locale("ru"); @@ -20,14 +21,16 @@ const localeText = ruRU.components.MuiLocalizationProvider.defaultProps.localeTe const root = createRoot(document.getElementById("root")!); root.render( - - - - - - - - - - + + + + + + + + + + + + ); diff --git a/src/model/quiz/create.ts b/src/model/quiz/create.ts index 4cab9648..ebe6d2c5 100644 --- a/src/model/quiz/create.ts +++ b/src/model/quiz/create.ts @@ -1,18 +1,34 @@ export interface CreateQuizRequest { - fingerprinting: boolean; - repeatable: boolean; - note_prevented: boolean; + /** set true for save deviceId */ + fingerprinting: boolean; + /** set true for allow user to repeat quiz */ + repeatable: boolean; + /** set true for save statistic of incomplete quiz passing */ + note_prevented: boolean; + /** set true for mail notification for each quiz passing */ mail_notifications: boolean; - unique_answers: boolean; - name: string; - description: string; - config: string; - status: string; - limit: number; - due_to: number; - time_of_passing: number; - pausable: boolean; - question_cnt: number; - super: boolean; - group_id: number; + /** set true for save statistics only for unique quiz passing */ + unique_answers: boolean; + /** name of quiz. max 280 length */ + name: string; + /** description of quiz */ + description: string; + /** config of quiz. serialized json for rules of quiz flow */ + config: string; + /** status of quiz. allow only '', 'draft', 'template', 'stop', 'start' */ + status: "draft" | "template" | "stop" | "start"; + /** limit is count of max quiz passing */ + limit: number; + /** last time when quiz is valid. timestamp in seconds */ + due_to: number; + /** seconds to pass quiz */ + time_of_passing: number; + /** true if it is allowed for pause quiz */ + pausable: boolean; + /** count of questions */ + question_cnt: number; + /** set true if squiz realize group functionality */ + super: boolean; + /** group of new quiz */ + group_id: number; } diff --git a/src/model/quiz/edit.ts b/src/model/quiz/edit.ts index 15d60e72..850ddfa3 100644 --- a/src/model/quiz/edit.ts +++ b/src/model/quiz/edit.ts @@ -1,23 +1,66 @@ +import { Quiz } from "./quiz"; + + export interface EditQuizRequest { + /** id of question for update */ id: number; + /** set true for storing fingerprints */ fp: boolean; + /** set true for allow to repeat quiz after passing */ rep: boolean; + /** set true for store unfinished passing */ note_prevented: boolean; + /** set true if we should send passing result on every passing */ mailing: boolean; + /** set true if we allow only one user quiz passing */ uniq: boolean; - name: string; - desc: string; - conf: string; - status: string; + /** new name of the quiz */ + name?: string; + /** new descriptions of the quiz */ + desc?: string; + /** new config of the quiz */ + conf?: string; + /** new status. only draft,template,stop,start allowed */ + status?: string; + /** max amount of quiz passing */ limit: number; + /** max time of quiz passing */ due_to: number; + /** max time to pass quiz */ time_of_passing: number; + /** allow to pause quiz to user */ pausable: boolean; - question_cnt: number; - super: boolean; - group_id: number; + /** count of questions */ + question_cnt?: number; + /** set true if squiz realize group functionality */ + super?: boolean; + /** group of new quiz */ + group_id?: number; } export interface EditQuizResponse { + /** id of new version of question */ updated: number; } + +export function quizToEditQuizRequest(quiz: Quiz): EditQuizRequest { + return { + id: quiz.id, + fp: quiz.fingerprinting, + rep: quiz.repeatable, + note_prevented: quiz.note_prevented, + mailing: quiz.mail_notifications, + uniq: quiz.unique_answers, + name: quiz.name, + desc: quiz.description, + conf: JSON.stringify(quiz.config), + status: quiz.status, + limit: quiz.limit, + due_to: quiz.due_to, + time_of_passing: quiz.time_of_passing, + pausable: quiz.pausable, + question_cnt: quiz.question_cnt, + super: quiz.super, + group_id: quiz.group_id, + }; +} diff --git a/src/model/quiz/getList.ts b/src/model/quiz/getList.ts index 074581dd..3abed8ad 100644 --- a/src/model/quiz/getList.ts +++ b/src/model/quiz/getList.ts @@ -1,17 +1,29 @@ +import { RawQuiz } from "./quiz"; + export interface GetQuizListRequest { - limit: number; - offset: number; - from: number; - to: number; - search: string; - status: string; - deleted: boolean; - archived: boolean; - super: boolean; - group_id: number; + /** max items on page */ + limit?: number; + /** page number */ + offset?: number; + /** start time of time period. timestamp in seconds */ + from?: number; + /** end time of time period. timestamp in seconds */ + to?: number; + /** string for fulltext search in titles of quizes */ + search?: string; + /** allow only - draft, template, timeout, stop, start, offlimit */ + status?: "" | "draft" | "template" | "timeout" | "stop" | "start" | "offlimit"; + /** get deleted quizes */ + deleted?: boolean; + /** get archived quizes */ + archived?: boolean; + /** set true if squiz realize group functionality */ + super?: boolean; + /** group of new quiz */ + group_id?: number; } export interface GetQuizListResponse { count: number; - items: unknown[]; // TODO + items: RawQuiz[]; } diff --git a/src/model/quiz/quiz.ts b/src/model/quiz/quiz.ts index b54824d5..49655b15 100644 --- a/src/model/quiz/quiz.ts +++ b/src/model/quiz/quiz.ts @@ -1,29 +1,134 @@ -export interface BackendQuiz { +import { QuizConfig, defaultQuizConfig } from "@model/quizSettings"; + + +export interface Quiz { + /** Id of created quiz */ id: number; + /** string id for customers */ qid: string; + /** true if quiz deleted */ deleted: boolean; + /** true if quiz archived */ archived: boolean; + /** set true for save deviceId */ fingerprinting: boolean; + /** set true for allow user to repeat quiz */ repeatable: boolean; + /** set true for save statistic of incomplete quiz passing */ note_prevented: boolean; + /** set true for mail notification for each quiz passing */ mail_notifications: boolean; + /** set true for save statistics only for unique quiz passing */ unique_answers: boolean; + /** name of quiz. max 280 length */ name: string; + /** description of quiz */ description: string; - config: string; + /** quiz config*/ + config: QuizConfig; + /** status of quiz. allow only '', 'draft', 'template', 'stop', 'start' */ status: string; + /** limit is count of max quiz passing */ limit: number; + /** last time when quiz is valid. timestamp in seconds */ due_to: number; + /** seconds to pass quiz */ time_of_passing: number; + /** true if it is allowed for pause quiz */ pausable: boolean; + /** version of quiz */ version: number; + /** version comment to version of quiz */ version_comment: string; + /** array of previous versions of quiz */ parent_ids: number[]; created_at: string; updated_at: string; + /** count of questions */ question_cnt: number; + /** count passings */ passed_count: number; + /** average time of passing */ average_time: number; + /** set true if squiz realize group functionality */ super: boolean; + /** group of new quiz */ group_id: number; } + +export interface RawQuiz { + /** Id of created quiz */ + id: number; + /** string id for customers */ + qid: string; + /** true if quiz deleted */ + deleted: boolean; + /** true if quiz archived */ + archived: boolean; + /** set true for save deviceId */ + fingerprinting: boolean; + /** set true for allow user to repeat quiz */ + repeatable: boolean; + /** set true for save statistic of incomplete quiz passing */ + note_prevented: boolean; + /** set true for mail notification for each quiz passing */ + mail_notifications: boolean; + /** set true for save statistics only for unique quiz passing */ + unique_answers: boolean; + /** name of quiz. max 280 length */ + name: string; + /** description of quiz */ + description: string; + /** config of quiz. serialized json for rules of quiz flow */ + config: string; + /** status of quiz. allow only '', 'draft', 'template', 'stop', 'start' */ + status: string; + /** limit is count of max quiz passing */ + limit: number; + /** last time when quiz is valid. timestamp in seconds */ + due_to: number; + /** seconds to pass quiz */ + time_of_passing: number; + /** true if it is allowed for pause quiz */ + pausable: boolean; + /** version of quiz */ + version: number; + /** version comment to version of quiz */ + version_comment: string; + /** array of previous versions of quiz */ + parent_ids: number[]; + created_at: string; + updated_at: string; + /** count of questions */ + question_cnt: number; + /** count passings */ + passed_count: number; + /** average time of passing */ + average_time: number; + /** set true if squiz realize group functionality */ + super: boolean; + /** group of new quiz */ + group_id: number; +} + +export function quizToRawQuiz(quiz: Quiz): RawQuiz { + return { + ...quiz, + config: JSON.stringify(quiz.config), + }; +} + +export function rawQuizToQuiz(rawQuiz: RawQuiz): Quiz { + let config = defaultQuizConfig; + + try { + config = JSON.parse(rawQuiz.config); + } catch (error) { + console.warn("Cannot parse quiz config from string, using default config", error); + } + + return { + ...rawQuiz, + config, + }; +} diff --git a/src/model/quizSettings.ts b/src/model/quizSettings.ts new file mode 100644 index 00000000..88befe6a --- /dev/null +++ b/src/model/quizSettings.ts @@ -0,0 +1,72 @@ +export const quizSetupSteps = { + 1: { displayStep: 1, text: "Настройка стартовой страницы" }, + 2: { displayStep: 1, text: "Настройка стартовой страницы" }, + 3: { displayStep: 1, text: "Настройка стартовой страницы" }, + 4: { displayStep: 2, text: "Задайте вопросы" }, + 5: { displayStep: 3, text: "Настройте авторезультаты" }, + 6: { displayStep: 3, text: "Настройте авторезультаты" }, + 7: { displayStep: 4, text: "Оценка графа карты вопросов" }, + 8: { displayStep: 5, text: "Настройте форму контактов" }, + 9: { displayStep: 6, text: "Установите квиз" }, + 10: { displayStep: 7, text: "Запустите рекламу" }, +} as const; + +export const maxQuizSetupSteps = Math.max(...Object.keys(quizSetupSteps).map(parseInt)); + +export const maxDisplayQuizSetupSteps = Math.max(...Object.values(quizSetupSteps).map(v => v.displayStep)); + +export type QuizSetupStep = keyof typeof quizSetupSteps; + +export interface QuizConfig { + type: "quiz" | "form"; + logo: string; + noStartPage: boolean; + startpageType: "standard" | "expanded" | "centered"; + startpage: { + description: string; + button: string; + position: string; + background: { + type: string; + desktop: string; + mobile: string; + video: string; + cycle: boolean; + }; + }; + info: { + phonenumber: string; + clickable: boolean; + orgname: string; + site: string; + law?: string; + }; + meta: string; +} + +export const defaultQuizConfig: QuizConfig = { + type: "quiz", + logo: "", + noStartPage: false, + startpageType: "standard", + startpage: { + description: "", + button: "", + position: "left", + background: { + type: "none", + desktop: "", + mobile: "", + video: "", + cycle: false, + }, + }, + info: { + phonenumber: "", + clickable: false, + orgname: "", + site: "", + law: "", + }, + meta: "", +}; diff --git a/src/pages/createQuize/FirstQuiz.tsx b/src/pages/createQuize/FirstQuiz.tsx index 2af27f50..a3d4e82c 100755 --- a/src/pages/createQuize/FirstQuiz.tsx +++ b/src/pages/createQuize/FirstQuiz.tsx @@ -1,8 +1,8 @@ import { Button, Typography } from "@mui/material"; -import { createQuiz } from "@root/quizesV2"; import SectionWrapper from "@ui_kit/SectionWrapper"; import ComplexNavText from "./ComplexNavText"; -import { useNavigate } from "react-router"; +import { createQuiz } from "@root/quizes/actions"; +import { useNavigate } from "react-router-dom"; export default function FirstQuiz() { diff --git a/src/pages/createQuize/MyQuizzesFull.tsx b/src/pages/createQuize/MyQuizzesFull.tsx index 390eda57..d2676fe0 100644 --- a/src/pages/createQuize/MyQuizzesFull.tsx +++ b/src/pages/createQuize/MyQuizzesFull.tsx @@ -1,88 +1,100 @@ +import { quizApi } from "@api/quiz"; +import { devlog } from "@frontend/kitui"; import { - Typography, - Box, - Button, - SxProps, - Theme, - useTheme, - useMediaQuery, + Box, + Button, + SxProps, + Theme, + Typography, + useMediaQuery, + useTheme, } from "@mui/material"; -import ComplexNavText from "./ComplexNavText"; -import QuizCard from "./QuizCard"; import SectionWrapper from "@ui_kit/SectionWrapper"; +import { isAxiosError } from "axios"; +import { enqueueSnackbar } from "notistack"; import React from "react"; -import { quizStore } from "@root/quizes"; -import FirstQuiz from "./FirstQuiz"; import { useNavigate } from "react-router-dom"; -import { createQuiz } from "@root/quizesV2"; +import useSWR from "swr"; +import ComplexNavText from "./ComplexNavText"; +import FirstQuiz from "./FirstQuiz"; +import QuizCard from "./QuizCard"; +import { setQuizes, createQuiz } from "@root/quizes/actions"; +import { useQuizArray } from "@root/quizes/hooks"; + + interface Props { - outerContainerSx?: SxProps; - children?: React.ReactNode; + outerContainerSx?: SxProps; + children?: React.ReactNode; } export default function MyQuizzesFull({ - outerContainerSx: sx, - children, + outerContainerSx: sx, + children, }: Props) { - const { listQuizes, updateQuizesList, removeQuiz, createBlank } = quizStore(); - const navigate = useNavigate(); - const theme = useTheme(); - const isMobile = useMediaQuery(theme.breakpoints.down(500)); + useSWR("quizes", () => quizApi.getList(), { + onSuccess: setQuizes, + onError: error => { + const message = isAxiosError(error) ? (error.response?.data ?? "") : ""; - return ( - <> - {Object.keys(listQuizes).length === 0 ? ( - - ) : ( - - - - Мои квизы - - - - {Object.values(listQuizes).map(({ id, name }) => ( - { - removeQuiz(id); - }} - onClickEdit={() => navigate(`/setting/${id}`)} - /> - ))} - - {children} - - )} - - ); + devlog("Error creating quiz", error); + enqueueSnackbar(`Не удалось получить квизы. ${message}`); + }, + }); + const quizArray = useQuizArray(); + const navigate = useNavigate(); + const theme = useTheme(); + const isMobile = useMediaQuery(theme.breakpoints.down(500)); + + return ( + <> + {quizArray.length === 0 ? ( + + ) : ( + + + + Мои квизы + + + + {quizArray.map(quiz => ( + + ))} + + {children} + + )} + + ); } diff --git a/src/pages/createQuize/QuizCard.tsx b/src/pages/createQuize/QuizCard.tsx index 8e1e422b..b25ffb40 100755 --- a/src/pages/createQuize/QuizCard.tsx +++ b/src/pages/createQuize/QuizCard.tsx @@ -1,142 +1,147 @@ -import { - Box, - Button, - IconButton, - Typography, - useTheme, - useMediaQuery, -} from "@mui/material"; import ChartIcon from "@icons/ChartIcon"; import LinkIcon from "@icons/LinkIcon"; import PencilIcon from "@icons/PencilIcon"; +import { Quiz } from "@model/quiz/quiz"; import MoreHorizIcon from "@mui/icons-material/MoreHoriz"; +import { + Box, + Button, + IconButton, + Typography, + useMediaQuery, + useTheme, +} from "@mui/material"; +import { deleteQuiz } from "@root/quizes/actions"; +import { useNavigate } from "react-router-dom"; + interface Props { - name: string; - openCount?: number; - applicationCount?: number; - conversionPercent?: number; - onClickDelete?: () => void; - onClickEdit?: () => void; + quiz: Quiz; + openCount?: number; + applicationCount?: number; + conversionPercent?: number; } export default function QuizCard({ - name, - openCount = 0, - applicationCount = 0, - conversionPercent = 0, - onClickDelete, - onClickEdit, + quiz, + openCount = 0, + applicationCount = 0, + conversionPercent = 0, }: Props) { - const theme = useTheme(); - const isMobile = useMediaQuery(theme.breakpoints.down(600)); + const theme = useTheme(); + const isMobile = useMediaQuery(theme.breakpoints.down(600)); + const navigate = useNavigate(); - return ( - - {name} - - - - быстрая ссылка ... - - - - - {openCount} - Открытий - - - {openCount} - Заявок - - - {openCount} % - Конверсия - - - - - - + + + + + + + )} + + - - - - - + {isMobile ? : } + + {quizConfig && + <> + + + + } + + {isTablet && [1, 2, 3].includes(currentStep) && ( + + + + + )} - - - - - - - )} - - - - {isMobile ? : } - - - - - {isTablet && activeStep === 1 && ( - - - - - )} - - - ); + + ); } diff --git a/src/pages/startPage/stepOne.tsx b/src/pages/startPage/stepOne.tsx index 33aabe58..b499ad34 100755 --- a/src/pages/startPage/stepOne.tsx +++ b/src/pages/startPage/stepOne.tsx @@ -1,71 +1,71 @@ -import { Box, Button, useTheme } from "@mui/material"; +import { Box, Button } from "@mui/material"; import CreationCard from "@ui_kit/CreationCard"; +import { useNavigate } from "react-router-dom"; import quizCreationImage1 from "../../assets/quiz-creation-1.png"; import quizCreationImage2 from "../../assets/quiz-creation-2.png"; -import { useParams } from "react-router-dom"; -import { quizStore } from "@root/quizes"; +import { setQuizType } from "@root/quizes/actions"; +import { useCurrentQuiz } from "@root/quizes/hooks"; + export default function StepOne() { - const params = Number(useParams().quizId); - const theme = useTheme(); + const navigate = useNavigate(); + const quiz = useCurrentQuiz(); + const config = quiz?.config; - const { listQuizes, updateQuizesList } = quizStore(); - return ( - - - - - - - ); + + + + + + ); } diff --git a/src/pages/startPage/steptwo.tsx b/src/pages/startPage/steptwo.tsx index 4aa18413..a1b90413 100755 --- a/src/pages/startPage/steptwo.tsx +++ b/src/pages/startPage/steptwo.tsx @@ -1,93 +1,99 @@ import { - Box, - Button, - Typography, - useTheme, - useMediaQuery, + Box, + Button, + Typography, + useMediaQuery, + useTheme, } from "@mui/material"; -import CardWithImage from "./CardWithImage"; +import { setQuizStartpageType } from "@root/quizes/actions"; +import { useCurrentQuiz } from "@root/quizes/hooks"; +import { useNavigate } from "react-router-dom"; import cardImage1 from "../../assets/card-1.png"; import cardImage2 from "../../assets/card-2.png"; import cardImage3 from "../../assets/card-3.png"; -import { quizStore } from "@root/quizes"; -import { useParams } from "react-router-dom"; +import CardWithImage from "./CardWithImage"; + export default function Steptwo() { - const params = Number(useParams().quizId); - const { listQuizes, updateQuizesList } = quizStore(); - const theme = useTheme(); - const isSmallMonitor = useMediaQuery(theme.breakpoints.down(1300)); + const navigate = useNavigate(); + const theme = useTheme(); + const isSmallMonitor = useMediaQuery(theme.breakpoints.down(1300)); + const quiz = useCurrentQuiz(); - return ( - - Стартовая страница - - - - - + const config = quiz?.config; + + if (!config) return null; + + return ( + + Стартовая страница + + + + + + + - - - ); + ); } diff --git a/src/stores/questionsV2.ts b/src/stores/questionsV2.ts index b6bcaecb..8798404c 100644 --- a/src/stores/questionsV2.ts +++ b/src/stores/questionsV2.ts @@ -80,7 +80,7 @@ export const setQuestionFieldOptimistic = async ( } catch (error) { if (isAxiosCanceledError(error)) return; - devlog("Error editing question", { error, question: question, currentUpdatedQuestion }); + devlog("Error editing question", { error, question, currentUpdatedQuestion }); enqueueSnackbar("Не удалось сохранить вопрос"); if (!savedOriginalQuestion) { devlog("Cannot rollback question"); diff --git a/src/stores/quizes/actions.ts b/src/stores/quizes/actions.ts new file mode 100644 index 00000000..e42de9c7 --- /dev/null +++ b/src/stores/quizes/actions.ts @@ -0,0 +1,195 @@ +import { quizApi } from "@api/quiz"; +import { devlog, getMessageFromFetchError } from "@frontend/kitui"; +import { quizToEditQuizRequest } from "@model/quiz/edit"; +import { Quiz, RawQuiz, rawQuizToQuiz } from "@model/quiz/quiz"; +import { QuizConfig, QuizSetupStep, maxQuizSetupSteps } from "@model/quizSettings"; +import { produce } from "immer"; +import { enqueueSnackbar } from "notistack"; +import { NavigateFunction } from "react-router-dom"; +import { QuizStore, useQuizStore } from "./store"; +import { isAxiosCanceledError } from "../../utils/isAxiosCanceledError"; + + +export const setQuizes = (quizes: RawQuiz[] | null) => setProducedState(state => { + state.quizById = {}; + if (quizes === null) return; + + quizes.forEach(quiz => state.quizById[quiz.id] = rawQuizToQuiz(quiz)); +}, { + type: "setQuizes", + quizes, +}); + +export const setQuiz = (quiz: Quiz) => setProducedState(state => { + state.quizById[quiz.id] = quiz; +}, { + type: "setQuiz", + quiz, +}); + +export const removeQuiz = (quizId: number) => setProducedState(state => { + delete state.quizById[quizId]; +}, { + type: "removeQuiz", + quizId, +}); + +export const setQuizField = ( + quizId: number, + field: T, + value: Quiz[T], +) => setProducedState(state => { + const quiz = state.quizById[quizId]; + if (!quiz) return; + + quiz[field] = value; +}, { + type: "setQuizField", + quizId, + field, + value, +}); + +export const updateQuiz = ( + quizId: number, + updateFn: (quiz: Quiz) => void, +) => setProducedState(state => { + const quiz = state.quizById[quizId]; + if (!quiz) return; + + updateFn(quiz); +}, { + type: "updateQuiz", + quizId, + updateFn: updateFn.toString(), +}); + +export const incrementCurrentStep = () => setProducedState(state => { + state.currentStep = Math.min( + maxQuizSetupSteps, state.currentStep + 1 + ) as QuizSetupStep; +}); + +export const decrementCurrentStep = () => setProducedState(state => { + state.currentStep = Math.max( + 1, state.currentStep - 1 + ) as QuizSetupStep; +}); + +export const setCurrentStep = (step: number) => setProducedState(state => { + state.currentStep = Math.max(0, Math.min(maxQuizSetupSteps, step)) as QuizSetupStep; +}); + +export const createQuiz = async (navigate: NavigateFunction) => { + try { + const quiz = await quizApi.create({ + name: "Quiz name", + description: "Quiz description", + }); + + setQuiz(rawQuizToQuiz(quiz)); + navigate(`/setting/${quiz.id}`); + } catch (error) { + devlog("Error creating quiz", error); + + const message = getMessageFromFetchError(error) ?? ""; + enqueueSnackbar(`Не удалось создать квиз. ${message}`); + } +}; + +export const deleteQuiz = async (quizId: number) => { + try { + await quizApi.delete(quizId); + + removeQuiz(quizId); + } catch (error) { + devlog("Error deleting quiz", error); + + const message = getMessageFromFetchError(error) ?? ""; + enqueueSnackbar(`Не удалось удалить квиз. ${message}`); + } +}; + +export const setQuizType = ( + quizId: number, + quizType: QuizConfig["type"], + navigate: NavigateFunction, +) => { + updateQuizWithFnOptimistic( + quizId, + quiz => { + quiz.config.type = quizType; + }, + navigate, + ); + incrementCurrentStep(); +}; + +export const setQuizStartpageType = ( + quizId: number, + startpageType: QuizConfig["startpageType"], + navigate: NavigateFunction, +) => { + updateQuizWithFnOptimistic( + quizId, + quiz => { + quiz.config.startpageType = startpageType; + }, + navigate, + ); + incrementCurrentStep(); +}; + +let savedOriginalQuiz: Quiz | null = null; +let controller: AbortController | null = null; + +export const updateQuizWithFnOptimistic = async ( + quizId: number, + updateFn: (quiz: Quiz) => void, + navigate: NavigateFunction, + rollbackOnError = true, +) => { + const quiz = useQuizStore.getState().quizById[quizId] ?? null; + if (!quiz) return; + + const currentUpdatedQuiz = produce(quiz, updateFn); + + controller?.abort(); + controller = new AbortController(); + savedOriginalQuiz ??= quiz; + + setQuiz(currentUpdatedQuiz); + try { + const { updated } = await quizApi.edit(quizToEditQuizRequest(currentUpdatedQuiz), controller.signal); + // await new Promise((resolve, reject) => setTimeout(reject, 2000, new Error("Api rejected"))); + + setQuizField(quiz.id, "version", updated); + navigate(`/setting/${quizId}`, { replace: true }); + + controller = null; + savedOriginalQuiz = null; + } catch (error) { + if (isAxiosCanceledError(error)) return; + + devlog("Error editing quiz", { error, quiz, currentUpdatedQuiz }); + enqueueSnackbar("Не удалось сохранить настройки квиза"); + + if (rollbackOnError) { + if (!savedOriginalQuiz) { + devlog("Cannot rollback quiz"); + throw new Error("Cannot rollback quiz"); + } + setQuiz(savedOriginalQuiz); + } + + controller = null; + savedOriginalQuiz = null; + } +}; + +function setProducedState( + recipe: (state: QuizStore) => void, + action?: A, +) { + useQuizStore.setState(state => produce(state, recipe), false, action); +} diff --git a/src/stores/quizes/hooks.ts b/src/stores/quizes/hooks.ts new file mode 100644 index 00000000..2aed2584 --- /dev/null +++ b/src/stores/quizes/hooks.ts @@ -0,0 +1,17 @@ +import { Quiz } from "@model/quiz/quiz"; +import { useParams } from "react-router-dom"; +import { useQuizStore } from "./store"; + + +export function useQuizArray(): Quiz[] { + const quizes = useQuizStore(state => state.quizById); + + return Object.values(quizes).flatMap(quiz => quiz ? [quiz] : []); +} + +export function useCurrentQuiz() { + const quizId = parseInt(useParams().quizId ?? ""); + const quiz = useQuizStore(state => state.quizById[quizId]); + + return quiz; +} diff --git a/src/stores/quizes/store.ts b/src/stores/quizes/store.ts new file mode 100644 index 00000000..66c85894 --- /dev/null +++ b/src/stores/quizes/store.ts @@ -0,0 +1,25 @@ +import { Quiz } from "@model/quiz/quiz"; +import { QuizSetupStep } from "@model/quizSettings"; +import { create } from "zustand"; +import { devtools } from "zustand/middleware"; + + +export type QuizStore = { + quizById: Record; + currentStep: QuizSetupStep; +}; + +const initialState: QuizStore = { + quizById: {}, + currentStep: 1, +}; + +export const useQuizStore = create()( + devtools( + () => initialState, + { + name: "QuizStore", + enabled: process.env.NODE_ENV === "development", + } + ) +); diff --git a/src/stores/quizesV2.ts b/src/stores/quizesV2.ts deleted file mode 100644 index a9034b5d..00000000 --- a/src/stores/quizesV2.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { quizApi } from "@api/quiz"; -import { devlog } from "@frontend/kitui"; -import { produce } from "immer"; -import { BackendQuiz } from "model/quiz/quiz"; -import { enqueueSnackbar } from "notistack"; -import { NavigateFunction } from "react-router"; -import { create } from "zustand"; -import { devtools } from "zustand/middleware"; - - -type QuizStore = { - quizes: Record; -}; - -const initialState: QuizStore = { - quizes: {}, -}; - -export const useQuizStore = create()( - devtools( - () => initialState, - { - name: "QuizStore", - enabled: process.env.NODE_ENV === "development", - } - ) -); - -export const setQuizes = (quizes: QuizStore["quizes"]) => useQuizStore.setState({ quizes }); - -export const setQuiz = (quiz: BackendQuiz) => setProducedState(state => { - state.quizes[quiz.id] = quiz; -}, { - type: "setQuiz", - quiz, -}); - -export const removeQuiz = (quizId: number) => setProducedState(state => { - delete state.quizes[quizId]; -}, { - type: "removeQuiz", - quizId, -}); - -export const setQuizField = ( - quizId: number, - field: T, - value: BackendQuiz[T], -) => setProducedState(state => { - const quiz = state.quizes[quizId]; - if (!quiz) return; - - quiz[field] = value; -}, { - type: "setQuizField", - quizId, - field, - value, -}); - -export const updateQuiz = ( - quizId: number, - updateFn: (quiz: BackendQuiz) => void, -) => setProducedState(state => { - const quiz = state.quizes[quizId]; - if (!quiz) return; - - updateFn(quiz); -}, { - type: "updateQuiz", - quizId, - updateFn: updateFn.toString(), -}); - -export const createQuiz = async (navigate: NavigateFunction) => { - try { - const quiz = await quizApi.create(); - - setQuiz(quiz); - navigate(`/settings/${quiz.id}`); - } catch (error) { - devlog("Error creating quiz", error); - enqueueSnackbar("Не удалось создать квиз"); - } -}; - -export const deleteQuiz = async (quizId: number) => { - try { - await quizApi.delete(quizId); - - removeQuiz(quizId); - } catch (error) { - devlog("Error deleting quiz", error); - enqueueSnackbar("Не удалось удалить квиз"); - } -}; - -function setProducedState( - recipe: (state: QuizStore) => void, - action?: A, -) { - useQuizStore.setState(state => produce(state, recipe), false, action); -} diff --git a/src/ui_kit/QuizPreview/QuizPreviewLayout.tsx b/src/ui_kit/QuizPreview/QuizPreviewLayout.tsx index bdfcd0d7..20a70179 100644 --- a/src/ui_kit/QuizPreview/QuizPreviewLayout.tsx +++ b/src/ui_kit/QuizPreview/QuizPreviewLayout.tsx @@ -1,12 +1,12 @@ import { Box, Button, LinearProgress, Paper, Typography } from "@mui/material"; import { questionStore } from "@root/questions"; import { - decrementCurrentQuestionIndex, - incrementCurrentQuestionIndex, - useQuizPreviewStore, + decrementCurrentQuestionIndex, + incrementCurrentQuestionIndex, + useQuizPreviewStore, } from "@root/quizPreview"; -import { DefiniteQuestionType } from "model/questionTypes/shared"; -import { FC, useEffect } from "react"; +import { AnyQuizQuestion } from "model/questionTypes/shared"; +import { useEffect } from "react"; import { useParams } from "react-router-dom"; import ArrowLeft from "../../assets/icons/questionsPage/arrowLeft"; import Date from "./QuizPreviewQuestionTypes/Date"; @@ -21,144 +21,140 @@ import Text from "./QuizPreviewQuestionTypes/Text"; import Variant from "./QuizPreviewQuestionTypes/Variant"; import Varimg from "./QuizPreviewQuestionTypes/Varimg"; -const QuestionPreviewComponentByType: Record> = { - variant: Variant, - images: Images, - varimg: Varimg, - emoji: Emoji, - text: Text, - select: Select, - date: Date, - number: Number, - file: File, - page: Page, - rating: Rating, -}; export default function QuizPreviewLayout() { - const quizId = useParams().quizId ?? 0; - const listQuestions = questionStore((state) => state.listQuestions); - const currentQuizStep = useQuizPreviewStore( - (state) => state.currentQuestionIndex - ); + const quizId = useParams().quizId ?? 0; + const listQuestions = questionStore((state) => state.listQuestions); + const currentQuizStep = useQuizPreviewStore( + (state) => state.currentQuestionIndex + ); - const quizQuestions = listQuestions[quizId] ?? []; - const nonDeletedQuizQuestions = quizQuestions.filter( - (question) => !question.deleted - ); - const maxCurrentQuizStep = - nonDeletedQuizQuestions.length > 0 ? nonDeletedQuizQuestions.length - 1 : 0; - const currentProgress = Math.floor( - (currentQuizStep / maxCurrentQuizStep) * 100 - ); + const quizQuestions = listQuestions[quizId] ?? []; + const nonDeletedQuizQuestions = quizQuestions.filter( + (question) => !question.deleted + ); + const maxCurrentQuizStep = + nonDeletedQuizQuestions.length > 0 ? nonDeletedQuizQuestions.length - 1 : 0; + const currentProgress = Math.floor( + (currentQuizStep / maxCurrentQuizStep) * 100 + ); - const currentQuestion = nonDeletedQuizQuestions[currentQuizStep]; - const QuestionComponent = currentQuestion - ? QuestionPreviewComponentByType[ - currentQuestion.type as DefiniteQuestionType - ] - : null; + const currentQuestion = nonDeletedQuizQuestions[currentQuizStep]; - const questionElement = QuestionComponent ? ( - - ) : null; + useEffect( + function resetCurrentQuizStep() { + if (currentQuizStep > maxCurrentQuizStep) { + decrementCurrentQuestionIndex(); + } + }, + [currentQuizStep, maxCurrentQuizStep] + ); - useEffect( - function resetCurrentQuizStep() { - if (currentQuizStep > maxCurrentQuizStep) { - decrementCurrentQuestionIndex(); - } - }, - [currentQuizStep, maxCurrentQuizStep] - ); - - return ( - - - {questionElement} - - - - - {nonDeletedQuizQuestions.length > 0 - ? `Вопрос ${currentQuizStep + 1} из ${ - nonDeletedQuizQuestions.length - }` - : "Нет вопросов"} - - {nonDeletedQuizQuestions.length > 0 && ( - - )} - - - - - - - - ); + + + + + + + {nonDeletedQuizQuestions.length > 0 + ? `Вопрос ${currentQuizStep + 1} из ${nonDeletedQuizQuestions.length + }` + : "Нет вопросов"} + + {nonDeletedQuizQuestions.length > 0 && ( + + )} + + + + + + + + ); +} + +function QuestionPreviewComponent({ question }: { + question: AnyQuizQuestion; +}) { + switch (question.type) { + case "variant": return ; + case "images": return ; + case "varimg": return ; + case "emoji": return ; + case "text": return ; + case "select": return