diff --git a/ CHANGELOG.md b/ CHANGELOG.md deleted file mode 100644 index fbc3ff99..00000000 --- a/ CHANGELOG.md +++ /dev/null @@ -1,2 +0,0 @@ -1.0.1 Страница заявок корректно отображает мультиответ -1.0.0 Добавлены фичи "мультиответ", "перенос строки в своём ответе", "свой ответ", "плейсхолдер своего ответа" \ No newline at end of file diff --git a/.husky/pre-commit b/.husky/pre-commit old mode 100644 new mode 100755 index 3ab2e13d..a203fb8d --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1,4 +1,21 @@ #!/usr/bin/env sh . "$(dirname -- "$0")/_/husky.sh" -yarn lint-staged --allow-empty +# yarn lint-staged --allow-empty + +if [ "$HUSKY_SKIP_CHANGELOG" != "1" ]; then # HUSKY_SKIP_CHANGELOG=1 git commit -m "--" + +# записываем в changelog инфо о коммите +bash .husky/scripts/update-changelog.sh + + # Проверяем, изменился ли CHANGELOG.md + if git diff --name-only | grep -q "CHANGELOG.md"; then + # Добавляем CHANGELOG.md в staging, если он был изменен + git add CHANGELOG.md + echo "CHANGELOG.md updated and added to staging" + else + echo "CHANGELOG.md not modified" + fi +else + echo "Skipping changelog update (HUSKY_SKIP_CHANGELOG=1)" +fi \ No newline at end of file diff --git a/.husky/scripts/update-changelog.sh b/.husky/scripts/update-changelog.sh new file mode 100644 index 00000000..1d8fe59c --- /dev/null +++ b/.husky/scripts/update-changelog.sh @@ -0,0 +1,64 @@ +#!/bin/bash + +# Проверяем, что мы в ветке staging +CURRENT_BRANCH=$(git symbolic-ref --short HEAD 2>/dev/null) + +if [ "$CURRENT_BRANCH" != "staging" ]; then + echo "Not in staging branch, skipping changelog update" + exit 0 +fi + +# Получаем последний commit message +COMMIT_MESSAGE=$(git log -1 --pretty=%B 2>/dev/null | head -1) + +if [ -z "$COMMIT_MESSAGE" ]; then + echo "No commit message found" + exit 0 +fi + +# Получаем текущую дату +CURRENT_DATE=$(date +%Y-%m-%d) +CHANGELOG_FILE="CHANGELOG.md" + +# Определяем новую версию +if [ -f "$CHANGELOG_FILE" ]; then + # Ищем последнюю версию в формате X.X.X (без v и ##) + LAST_VERSION=$(grep -E '^[0-9]+\.[0-9]+\.[0-9]+' "$CHANGELOG_FILE" | head -1 | grep -Eo '[0-9]+\.[0-9]+\.[0-9]+') + + if [ -n "$LAST_VERSION" ]; then + # Увеличиваем patch версию (третью цифру) + IFS='.' read -r MAJOR MINOR PATCH <<< "$LAST_VERSION" + NEW_VERSION="$MAJOR.$MINOR.$((PATCH + 1))" + echo "Found last version: $LAST_VERSION, new version: $NEW_VERSION" + else + # Если не нашли версию, начинаем с 1.0.1 + NEW_VERSION="1.0.1" + echo "No version found, starting from: $NEW_VERSION" + fi +else + NEW_VERSION="1.0.0" + echo "CHANGELOG.md not found, starting from: $NEW_VERSION" +fi + +# Создаем временный файл +TEMP_FILE=$(mktemp) + +# Добавляем новую запись БЕЗ ##, БЕЗ v, БЕЗ переноса строки +echo "${NEW_VERSION} _ ${CURRENT_DATE} _ ${COMMIT_MESSAGE}" > "$TEMP_FILE" + +# Добавляем существующее содержимое БЕЗ пустых строк между записями +if [ -f "$CHANGELOG_FILE" ]; then + # Убираем пустые строки между записями и добавляем содержимое + awk 'NF' "$CHANGELOG_FILE" >> "$TEMP_FILE" +else + # Создаем базовую структуру для нового файла + echo "# Changelog" >> "$TEMP_FILE" + echo "" >> "$TEMP_FILE" + echo "All notable changes to this project will be documented in this file." >> "$TEMP_FILE" + echo "" >> "$TEMP_FILE" +fi + +# Заменяем оригинальный файл +mv "$TEMP_FILE" "$CHANGELOG_FILE" + +echo "CHANGELOG.md updated to version ${NEW_VERSION}" \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index e69de29b..dece325d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -0,0 +1,12 @@ +1.0.11 _ 2025-10-06 _ Merge branch 'staging' +1.0.10 _ 2025-10-05 _ utm +1.0.9 _ 2025-10-05 _ utm +1.0.8 _ 2025-10-05 _ замена инпут на текстареа +1.0.7 _ 2025-10-05 _ замена инпут на текстареа +1.0.6 _ 2025-09-19 _ логика включения таймера +1.0.5 _ 2025-09-18 _ особые условия для вывода картинок +1.0.4 _ 2025-09-14 _ особые условия для вывода картинок +1.0.3 _ 2025-09-12 _ среднее время не учитывает нули +1.0.2 _ 2025-09-07 _ добавлена автозапись в стейджинг +1.0.1 Страница заявок корректно отображает мультиответ +1.0.0 Добавлены фичи "мультиответ", "перенос строки в своём ответе", "свой ответ", "плейсхолдер своего ответа" diff --git a/src/api/leadtarget.ts b/src/api/leadtarget.ts new file mode 100644 index 00000000..c85befcf --- /dev/null +++ b/src/api/leadtarget.ts @@ -0,0 +1,101 @@ +import { makeRequest } from "@frontend/kitui"; +import { parseAxiosError } from "@utils/parse-error"; + +export type LeadTargetType = "mail" | "telegram" | "whatsapp" | "webhook"; + +export interface LeadTargetModel { + id: number; + accountID: string; + type: LeadTargetType; + quizID: number; + target: string; // содержит подстроку "zapier" или "postback" + inviteLink?: string; + deleted?: boolean; + createdAt?: string; +} + +const API_URL = `${process.env.REACT_APP_DOMAIN}/squiz`; + +export const getLeadTargetsByQuiz = async ( + quizId: number, +): Promise<[LeadTargetModel[] | null, string?]> => { + try { + const items = await makeRequest({ + method: "GET", + url: `${API_URL}/account/leadtarget/${quizId}`, + }); + + return [items]; + } catch (nativeError) { + const [error] = parseAxiosError(nativeError); + return [null, `Не удалось получить цели лида. ${error}`]; + } +}; + +export const createLeadTarget = async ( + body: { + type: LeadTargetType; + quizID: number; + target: string; + name?: string; + }, +): Promise<[LeadTargetModel | true | null, string?]> => { + try { + const response = await makeRequest({ + method: "POST", + url: `${API_URL}/account/leadtarget`, + body, + }); + + return [response]; + } catch (nativeError) { + const [error] = parseAxiosError(nativeError); + return [null, `Не удалось создать цель лида. ${error}`]; + } +}; + +export const updateLeadTarget = async ( + body: { + id: number; + target: string; + }, +): Promise<[LeadTargetModel | null, string?]> => { + try { + const updated = await makeRequest({ + method: "PUT", + url: `${API_URL}/account/leadtarget`, + body, + }); + + return [updated]; + } catch (nativeError) { + const [error] = parseAxiosError(nativeError); + return [null, `Не удалось обновить цель лида. ${error}`]; + } +}; + +export const deleteLeadTarget = async ( + id: number, +): Promise<[true | null, string?]> => { + try { + await makeRequest({ + method: "DELETE", + url: `${API_URL}/account/leadtarget/${id}`, + }); + + return [true]; + } catch (nativeError) { + const [error] = parseAxiosError(nativeError); + return [null, `Не удалось удалить цель лида. ${error}`]; + } +}; + +export const leadTargetApi = { + getByQuiz: getLeadTargetsByQuiz, + create: createLeadTarget, + update: updateLeadTarget, + delete: deleteLeadTarget, +}; + + + diff --git a/src/assets/icons/OrangeYoutube.tsx b/src/assets/icons/OrangeYoutube.tsx new file mode 100755 index 00000000..42524f75 --- /dev/null +++ b/src/assets/icons/OrangeYoutube.tsx @@ -0,0 +1,13 @@ +import { Box, SxProps } from "@mui/material"; + +export default function OrangeYoutube(sx: SxProps) { + return ( + + + + + + ); +} diff --git a/src/pages/Analytics/Analytics.tsx b/src/pages/Analytics/Analytics.tsx index c64912f6..04feffa9 100644 --- a/src/pages/Analytics/Analytics.tsx +++ b/src/pages/Analytics/Analytics.tsx @@ -18,6 +18,7 @@ import SectionWrapper from "@ui_kit/SectionWrapper"; import { General } from "./General"; import { AnswersStatistics } from "./Answers/AnswersStatistics"; import { Devices } from "./Devices"; +import AnalyticsSkeleton from "./AnalyticsSkeleton"; import { setQuizes } from "@root/quizes/actions"; import { useQuizStore } from "@root/quizes/store"; @@ -32,11 +33,17 @@ import { ReactComponent as ResetIcon } from "@icons/Analytics/reset.svg"; import type { Moment } from "moment"; import type { ReactNode } from "react"; import type { Quiz } from "@model/quiz/quiz"; +import { useCurrentQuiz } from "@/stores/quizes/hooks"; +import { useQuestions } from "@/stores/questions/hooks"; export default function Analytics() { - const { quizes, editQuizId } = useQuizStore(); - const [quiz, setQuiz] = useState({} as Quiz); + + const quiz = useCurrentQuiz(); + const globalQuestions = useQuestions({quizId: quiz?.backendId}).questions; + + + const [isOpen, setOpen] = useState(false); const [isOpenEnd, setOpenEnd] = useState(false); const [from, setFrom] = useState(null); @@ -45,30 +52,25 @@ export default function Analytics() { const theme = useTheme(); const isMobile = useMediaQuery(theme.breakpoints.down(600)); - const { devices, general, questions } = useAnalytics({ - ready: Boolean(Object.keys(quiz).length), - quizId: editQuizId?.toString() || "", + const { devices, general, questions, isLoading } = useAnalytics({ + ready: quiz ? Boolean(Object.keys(quiz).length) : false, + quizId: quiz?.backendId?.toString() || "", from, to, }); const resetTime = () => { - setFrom(moment(new Date(quiz.created_at))); + setFrom(moment(new Date(quiz?.created_at))); setTo(moment().add(1, "days")); }; useEffect(() => { - if (quizes.length > 0) { - const quiz = quizes.find((q) => q.backendId === editQuizId); - if (quiz === undefined) throw new Error("Не удалось получить квиз"); - setQuiz(quiz); - setFrom(moment(new Date(quiz.created_at))); - } - }, [quizes]); + if (quiz) setFrom(moment(new Date(quiz?.created_at))); + }, [quiz]); useEffect(() => { const getData = async (): Promise => { - if (editQuizId !== null) { + if (quiz?.backendId !== null) { const [gottenQuizes, gottenQuizesError] = await quizApi.getList(); if (gottenQuizesError) { @@ -85,8 +87,8 @@ export default function Analytics() { }, []); useLayoutEffect(() => { - if (editQuizId === undefined) redirect("/list"); - }, [editQuizId]); + if (quiz?.backendId === undefined) redirect("/list"); + }, [quiz?.backendId]); const handleClose = () => { setOpen(false); @@ -255,12 +257,18 @@ export default function Analytics() { {isMobile ? : "Сбросить"} - 0} - /> - - + {isLoading ? ( + + ) : ( + <> + 0} + /> + + + + )} ); diff --git a/src/pages/Analytics/AnalyticsSkeleton.tsx b/src/pages/Analytics/AnalyticsSkeleton.tsx new file mode 100644 index 00000000..71b4886b --- /dev/null +++ b/src/pages/Analytics/AnalyticsSkeleton.tsx @@ -0,0 +1,102 @@ +import { Box, Paper, Skeleton, Typography, useMediaQuery, useTheme } from "@mui/material"; + +export default function AnalyticsSkeleton() { + const theme = useTheme(); + const isTablet = useMediaQuery(theme.breakpoints.down(1000)); + const isMobile = useMediaQuery(theme.breakpoints.down(700)); + + const card = ( + + + + + + ); + + return ( + + + + Ключевые метрики + + + {card} + {card} + {card} + {card} + + + + + + Статистика по ответам + + + + + + + + + + + + + + + {[...Array(4)].map((_, idx) => ( + + + + ))} + + + + + + + Статистика пользователей + + + {[...Array(3)].map((_, i) => ( + + + + + {[...Array(4)].map((_, idx) => ( + + + + + ))} + + + ))} + + + + ); +} + + diff --git a/src/pages/Analytics/Answers/Answers.tsx b/src/pages/Analytics/Answers/Answers.tsx index fd1d8220..d1b88cf7 100644 --- a/src/pages/Analytics/Answers/Answers.tsx +++ b/src/pages/Analytics/Answers/Answers.tsx @@ -3,6 +3,7 @@ import type { PaginationRenderItemParams } from "@mui/material"; import { Box, ButtonBase, + IconButton, Input, LinearProgress, Pagination as MuiPagination, @@ -19,6 +20,10 @@ import { ReactComponent as LeftArrowIcon } from "@icons/Analytics/leftArrow.svg" import { ReactComponent as RightArrowIcon } from "@icons/Analytics/rightArrow.svg"; import { extractOrder } from "@utils/extractOrder"; import { parseTitle } from "../utils/parseTitle"; +import { AnyTypedQuizQuestion } from "@frontend/squzanswerer"; +import InsertDriveFileIcon from '@mui/icons-material/InsertDriveFile'; +import { timewebContent, timewebContentFile } from "@/pages/QuizAnswersPage/cardAnswers/helper"; +import { useCurrentQuiz } from "@/stores/quizes/hooks"; type AnswerProps = { title: string; @@ -28,6 +33,7 @@ type AnswerProps = { type AnswersProps = { data: Record> | null; + globalQuestions: AnyTypedQuizQuestion[]; }; type PaginationProps = { @@ -40,9 +46,13 @@ const Answer = ({ title, percent, highlight }: AnswerProps) => { const theme = useTheme(); const parsedTitle = parseTitle(title); - console.log("parsedTitle: " + parsedTitle); + console.log("Привет, я Answer. И вот что я о себе знаю:") + console.log("-------------------------------------------------------------------") + console.log("{ title, percent, highlight }") + console.log({ title, percent, highlight }) + return ( - + { ); }; -export const Answers: FC = ({ data }) => { +export const Answers: FC = ({ data, globalQuestions }) => { + const quiz = useCurrentQuiz(); const [page, setPage] = useState(1); const theme = useTheme(); const answers = useMemo(() => { @@ -210,17 +221,32 @@ export const Answers: FC = ({ data }) => { ); }, [data]); const currentAnswer = answers[page - 1]; + const globalQuestion = globalQuestions.find(e => { + console.log("---") + console.log("Привет, я ищу глоб вопро ", currentAnswer[0].slice(0, -4)) + console.log(" c ", e.title) + console.log("---") + console.log(e.title === currentAnswer[0].slice(0, -4)) + return e.title === currentAnswer[0].slice(0, -4) + }); + + console.log("globalQuestionglobalQuestionglobalQuestionglobalQuestionglobalQuestionglobalQuestion") + console.log(globalQuestion) + const percentsSum = Object.values(currentAnswer?.[1] ?? {}).reduce( (total, item) => (total += item), 0, ); + + console.log("currentAnswer") + console.log(currentAnswer) const currentAnswerExtended = percentsSum >= 100 ? Object.entries(currentAnswer?.[1] ?? {}) : [ - ...Object.entries(currentAnswer?.[1] ?? {}), - ["Другое", 100 - percentsSum] as [string, number], - ]; + ...Object.entries(currentAnswer?.[1] ?? {}), + ["Другое", 100 - percentsSum] as [string, number], + ]; if (!data) { return ( @@ -287,16 +313,92 @@ export const Answers: FC = ({ data }) => { */} - {currentAnswerExtended.map(([title, percent], index) => ( - - ))} - + {currentAnswerExtended.map(([title, percent], index) => { + console.log("kdgjhskjdfhkhsdgksfdhgjsdhfgkjsfdhgkj") + console.log("ewrqwrwr") + console.log("checkFileExtension") + console.log(checkFileExtension(title)) + console.log("quiz?.backendId") + console.log(quiz?.backendId) + console.log("globalQuestion?.backendId") + console.log(globalQuestion?.backendId) + console.log("data") + console.log(data) + if (checkFileExtension(title) && quiz?.backendId && globalQuestion?.backendId) { + return ( + + { + + + + console.log(timewebContentFile(quiz?.qid.toString(), title, globalQuestion?.backendId.toString())) + + const link = document.createElement('a'); + link.href = timewebContentFile(quiz?.qid.toString(), title, globalQuestion?.backendId.toString()); + link.download = title + link.style.display = 'none'; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + + }} + > + + + < Answer + key={index} + title={title} + percent={percent} + highlight={!index} + /> + + ) + } else { + return ( + < Answer + key={index} + title={title} + percent={percent} + highlight={!index} + /> + ) + } + })} + - + ); }; +function checkFileExtension(filename: string, maxLength = 6) { + if (typeof filename !== 'string') return false; + + // Ищем последнюю точку в строке + const lastDotIndex = filename.lastIndexOf('.'); + + // Если точки нет или она в конце строки - возвращаем false + if (lastDotIndex === -1 || lastDotIndex === filename.length - 1) { + return false; + } + + // Получаем расширение (часть после последней точки) + const extension = filename.slice(lastDotIndex + 1); + + // Проверяем что расширение состоит только из букв и не превышает максимальную длину + return /^[a-zA-Zа-яА-ЯёЁ]+$/.test(extension) && extension.length <= maxLength; +} \ No newline at end of file diff --git a/src/pages/Analytics/Answers/AnswersStatistics.tsx b/src/pages/Analytics/Answers/AnswersStatistics.tsx index 1a9ff469..3929fabd 100644 --- a/src/pages/Analytics/Answers/AnswersStatistics.tsx +++ b/src/pages/Analytics/Answers/AnswersStatistics.tsx @@ -5,12 +5,14 @@ import { QuestionsResponse } from "@api/statistic"; import { FC } from "react"; import { Funnel } from "./FunnelAnswers/Funnel"; import { Results } from "./Results"; +import { AnyTypedQuizQuestion } from "@frontend/squzanswerer"; type AnswersStatisticsProps = { data: QuestionsResponse | null; + globalQuestions: AnyTypedQuizQuestion[]; }; -export const AnswersStatistics: FC = ({ data }) => { +export const AnswersStatistics: FC = ({ data, globalQuestions }) => { const theme = useTheme(); const isSmallMonitor = useMediaQuery(theme.breakpoints.down(1150)); const isMobile = useMediaQuery(theme.breakpoints.down(850)); @@ -33,7 +35,7 @@ export const AnswersStatistics: FC = ({ data }) => { gap: "40px", }} > - + diff --git a/src/pages/Analytics/General.tsx b/src/pages/Analytics/General.tsx index 78ce3a9f..339e380c 100644 --- a/src/pages/Analytics/General.tsx +++ b/src/pages/Analytics/General.tsx @@ -41,14 +41,20 @@ const GeneralItem = ({ const data = Object.entries(general).sort( ([nextValue], [currentValue]) => Number(nextValue) - Number(currentValue), ); - const days = data.map(([value]) => value); - const numberValue = calculateTime - ? Object.values(general).reduce((total, value) => total + value, 0) / Object.values(general).length - : conversionValue + +let numberValue = 0 + + numberValue = conversionValue ? conversionValue : Object.values(general).reduce((total, item) => total + item, 0); + if (calculateTime) { + const values = Object.values(general).filter(e => e); + numberValue = values.reduce((total, value) => total + value, 0) / Object.values(values).length; + } + + if ( Object.keys(general).length === 0 || Object.values(general).every((item) => item === 0) @@ -150,7 +156,7 @@ export const General: FC = ({ data, day }) => { (total, item) => total + item, 0, ); - const openSum = Object.values(generalResponse.Open).reduce( + const openSum = Object.values(generalResponse.Open).filter(e => e).reduce( (total, item) => total + item, 0, ); diff --git a/src/pages/Analytics/utils/parseTitle.ts b/src/pages/Analytics/utils/parseTitle.ts index 0bb29846..cd535fc6 100644 --- a/src/pages/Analytics/utils/parseTitle.ts +++ b/src/pages/Analytics/utils/parseTitle.ts @@ -19,25 +19,19 @@ export const parseTitle = (title: string): string => { try { // Пытаемся распарсить как JSON const parsed = JSON.parse(cleanTitle); - console.log("parsed object:", parsed); - console.log("parsed.Image:", parsed.Image); - console.log("parsed.Description:", parsed.Description); // Проверяем, что это объект с полями Image и Description (специфичный для вопросов типа images и varimg) if (parsed && typeof parsed === 'object' && 'Image' in parsed && 'Description' in parsed) { - console.log("Returning Description:", parsed.Description); return parsed.Description || "нет названия"; } // Если это не объект с Image и Description, возвращаем исходную строку - console.log("Not Image/Description object, returning original title"); return title; } catch (error) { // Если парсинг не удался, возвращаем исходную строку - console.log("JSON parse error, returning original title"); return title; } }; \ No newline at end of file diff --git a/src/pages/DesignPage/DesignFilling.tsx b/src/pages/DesignPage/DesignFilling.tsx index 706898dc..9a7a5f89 100644 --- a/src/pages/DesignPage/DesignFilling.tsx +++ b/src/pages/DesignPage/DesignFilling.tsx @@ -10,6 +10,7 @@ import { updateQuiz } from "@root/quizes/actions"; import { useCurrentQuiz } from "@root/quizes/hooks"; import type { DesignItem } from "./DesignGroup"; import { DesignGroup } from "./DesignGroup"; +import pinkScrollbar from "@utils/pinkScrollbar"; import Desgin1 from "@icons/designs/smallSize/design1.jpg"; import Desgin2 from "@icons/designs/smallSize/design2.jpg"; @@ -133,15 +134,7 @@ export const DesignFilling = ({ padding: "20px", height: "100%", overflow: "auto", - scrollbarWidth: "auto", - "&::-webkit-scrollbar": { - display: "block", - width: "8px", - }, - "&::-webkit-scrollbar-thumb": { - backgroundColor: theme.palette.brightPurple.main, - borderRadius: "4px", - }, + ...pinkScrollbar(theme) }} > @@ -199,3 +192,4 @@ export const DesignFilling = ({ ); }; + diff --git a/src/pages/IntegrationsPage/IntegrationsModal/InstructionYoutubeLink.tsx b/src/pages/IntegrationsPage/IntegrationsModal/InstructionYoutubeLink.tsx new file mode 100644 index 00000000..4c085ad3 --- /dev/null +++ b/src/pages/IntegrationsPage/IntegrationsModal/InstructionYoutubeLink.tsx @@ -0,0 +1,36 @@ +import { FC } from "react"; +import { Box, Link, BoxProps } from "@mui/material"; +import OrangeYoutube from "@/assets/icons/OrangeYoutube"; + +interface InstructionYoutubeLinkProps extends BoxProps {} + +const InstructionYoutubeLink: FC = ({ sx, ...props }) => { + return ( + + + Смотреть инструкцию + + + ); +}; + +export default InstructionYoutubeLink; \ No newline at end of file diff --git a/src/pages/IntegrationsPage/IntegrationsModal/Postback/index.tsx b/src/pages/IntegrationsPage/IntegrationsModal/Postback/index.tsx index 3b7af294..037e54f9 100644 --- a/src/pages/IntegrationsPage/IntegrationsModal/Postback/index.tsx +++ b/src/pages/IntegrationsPage/IntegrationsModal/Postback/index.tsx @@ -1,7 +1,13 @@ -import { FC } from "react"; -import { Dialog, IconButton, Typography, useMediaQuery, useTheme, Box } from "@mui/material"; +import { FC, useState, useEffect } from "react"; +import { Dialog, IconButton, Typography, useMediaQuery, useTheme, Box, Button } from "@mui/material"; import CloseIcon from "@mui/icons-material/Close"; import { Quiz } from "@/model/quiz/quiz"; +import CustomTextField from "@/ui_kit/CustomTextField"; +import { createLeadTarget, getLeadTargetsByQuiz, deleteLeadTarget, updateLeadTarget } from "@/api/leadtarget"; +import { useFormik } from "formik"; +import InstructionYoutubeLink from "@/pages/IntegrationsPage/IntegrationsModal/InstructionYoutubeLink"; +import { useSnackbar } from "notistack"; +import { useLeadTargets } from "@/pages/IntegrationsPage/hooks/useLeadTargets"; type PostbackModalProps = { isModalOpen: boolean; @@ -10,15 +16,74 @@ type PostbackModalProps = { quiz: Quiz; }; -export const PostbackModal: FC = ({ - isModalOpen, - handleCloseModal, - companyName, - quiz +export const PostbackModal: FC = ({ + isModalOpen, + handleCloseModal, + companyName, + quiz }) => { const theme = useTheme(); const isMobile = useMediaQuery(theme.breakpoints.down(600)); const isTablet = useMediaQuery(theme.breakpoints.down(1000)); + const [isSaving, setIsSaving] = useState(false); + const { enqueueSnackbar } = useSnackbar(); + + const handleSubmit = async (values: { token: string; domain: string }) => { + const tokenValue = (values.token || "").trim(); + const target = (values.domain || "").trim(); + try { + setIsSaving(true); + // 1) Асинхронно получаем текущие цели + const [items] = await getLeadTargetsByQuiz(quiz.backendId); + const existing = (items ?? []).filter((t) => t.type === "webhook"); + if (!tokenValue && !target) { + const deletePromises = existing.map((t) => deleteLeadTarget(t.id)); + await Promise.all(deletePromises); + enqueueSnackbar("Postback удален", { variant: "success" }); + } else if (existing.length > 0) { + const [first, ...extra] = existing; + await Promise.all([ + updateLeadTarget({ id: first.id, target }), + ...extra.map((t) => deleteLeadTarget(t.id)), + ]); + enqueueSnackbar("Postback обновлен", { variant: "success" }); + } else { + await createLeadTarget({ + type: "webhook", + quizID: quiz.backendId, + target, + name: tokenValue || undefined, + }); + enqueueSnackbar("Postback сохранен", { variant: "success" }); + } + + await refresh(); + } catch (error) { + enqueueSnackbar("Ошибка при сохранении", { variant: "error" }); + } finally { + setIsSaving(false); + } + }; + + const { isLoading, postbackTarget, refresh } = useLeadTargets(quiz?.backendId, isModalOpen); + + const formik = useFormik<{ token: string; domain: string }>({ + initialValues: { token: "", domain: postbackTarget?.target ?? "" }, + onSubmit: handleSubmit, + }); + + useEffect(() => { + formik.setFieldValue("domain", postbackTarget?.target ?? ""); + }, [postbackTarget?.target]); + + useEffect(() => { + if (isModalOpen) { + setTimeout(() => { + const input = document.getElementById("postback-domain") as HTMLInputElement | null; + input?.focus(); + }, 0); + } + }, [isModalOpen]); return ( = ({ PaperProps={{ sx: { maxWidth: isTablet ? "100%" : "919px", - height: "658px", + height: isMobile ? "303px" : "214px", + // height: "314px", borderRadius: "12px", }, }} > - - + - + + + + + + {!isMobile && } + + + - Интеграция с {companyName ? companyName : "Postback"} - - - - - - - - Интеграция с Postback находится в разработке. - + + Домен + + {isLoading ? ( + + ) : ( + formik.setFieldValue("domain", e.target.value)} + maxLength={150} + /> + )} + + + + {isMobile && } ); diff --git a/src/pages/IntegrationsPage/IntegrationsModal/Zapier/index.tsx b/src/pages/IntegrationsPage/IntegrationsModal/Zapier/index.tsx index 39483a96..bb8255d3 100644 --- a/src/pages/IntegrationsPage/IntegrationsModal/Zapier/index.tsx +++ b/src/pages/IntegrationsPage/IntegrationsModal/Zapier/index.tsx @@ -1,7 +1,13 @@ -import { FC } from "react"; -import { Dialog, IconButton, Typography, useMediaQuery, useTheme, Box } from "@mui/material"; +import { FC, useState, useEffect } from "react"; +import { Dialog, IconButton, Typography, useMediaQuery, useTheme, Box, Button } from "@mui/material"; import CloseIcon from "@mui/icons-material/Close"; import { Quiz } from "@/model/quiz/quiz"; +import CustomTextField from "@/ui_kit/CustomTextField"; +import { createLeadTarget, getLeadTargetsByQuiz, deleteLeadTarget, updateLeadTarget } from "@/api/leadtarget"; +import { useFormik } from "formik"; +import InstructionYoutubeLink from "@/pages/IntegrationsPage/IntegrationsModal/InstructionYoutubeLink"; +import { useSnackbar } from "notistack"; +import { useLeadTargets } from "@/pages/IntegrationsPage/hooks/useLeadTargets"; type ZapierModalProps = { isModalOpen: boolean; @@ -10,15 +16,76 @@ type ZapierModalProps = { quiz: Quiz; }; -export const ZapierModal: FC = ({ - isModalOpen, - handleCloseModal, - companyName, - quiz +export const ZapierModal: FC = ({ + isModalOpen, + handleCloseModal, + companyName, + quiz }) => { const theme = useTheme(); const isMobile = useMediaQuery(theme.breakpoints.down(600)); const isTablet = useMediaQuery(theme.breakpoints.down(1000)); + const [isSaving, setIsSaving] = useState(false); + const { enqueueSnackbar } = useSnackbar(); + + const handleSubmit = async (values: { webhookUrl: string }) => { + const target = (values.webhookUrl || "").trim(); + try { + setIsSaving(true); + // 1) Асинхронно получаем текущие цели + const [items] = await getLeadTargetsByQuiz(quiz.backendId); + const existing = (items ?? []).filter((t) => t.type === "webhook"); + + if (!target) { + // Пустое значение — удаляем все + const deletePromises = existing.map((t) => deleteLeadTarget(t.id)); + await Promise.all(deletePromises); + enqueueSnackbar("Webhook удален", { variant: "success" }); + } else if (existing.length > 0) { + // Уже существует — обновляем первый и удаляем все лишние + const [first, ...extra] = existing; + await Promise.all([ + updateLeadTarget({ id: first.id, target }), + ...extra.map((t) => deleteLeadTarget(t.id)), + ]); + enqueueSnackbar("Webhook обновлен", { variant: "success" }); + } else { + // Не существует — создаем + await createLeadTarget({ + type: "webhook", + quizID: quiz.backendId, + target, + }); + enqueueSnackbar("Webhook сохранен", { variant: "success" }); + } + + await refresh(); + } catch (error) { + enqueueSnackbar("Ошибка при сохранении", { variant: "error" }); + } finally { + setIsSaving(false); + } + }; + + const { isLoading, zapierTarget, refresh } = useLeadTargets(quiz?.backendId, isModalOpen); + + const formik = useFormik<{ webhookUrl: string }>({ + initialValues: { webhookUrl: zapierTarget?.target ?? "" }, + onSubmit: handleSubmit, + }); + + useEffect(() => { + formik.setFieldValue("webhookUrl", zapierTarget?.target ?? ""); + }, [zapierTarget?.target]); + + useEffect(() => { + if (isModalOpen) { + setTimeout(() => { + const input = document.getElementById("zapier-webhook-url") as HTMLInputElement | null; + input?.focus(); + }, 0); + } + }, [isModalOpen]); return ( = ({ PaperProps={{ sx: { maxWidth: isTablet ? "100%" : "919px", - height: "658px", + height: isMobile ? "303px" : "195px", borderRadius: "12px", }, }} @@ -52,12 +119,13 @@ export const ZapierModal: FC = ({ color: theme.palette.grey2.main, }} > - Интеграция с {companyName ? companyName : "Zapier"} + Автоматизация с {companyName ? companyName : "Zapier"} + = ({ overflow: "auto", }} > - - Интеграция с Zapier находится в разработке. - + {!isMobile && } + + + + + URL webhook + + + + + {isLoading ? ( + + ) : ( + formik.setFieldValue("webhookUrl", e.target.value)} + maxLength={150} + /> + )} + + + + {isMobile && } + diff --git a/src/pages/IntegrationsPage/IntegrationsPage.tsx b/src/pages/IntegrationsPage/IntegrationsPage.tsx index ce2af29a..1b856e14 100644 --- a/src/pages/IntegrationsPage/IntegrationsPage.tsx +++ b/src/pages/IntegrationsPage/IntegrationsPage.tsx @@ -5,6 +5,7 @@ import { useCurrentQuiz } from "@root/quizes/hooks"; import { useQuizStore } from "@root/quizes/store"; import { useNavigate } from "react-router-dom"; import { PartnersBoard } from "./PartnersBoard/PartnersBoard"; +import { getLeadTargetsByQuiz, LeadTargetModel } from "@/api/leadtarget"; import { QuizMetricType } from "@model/quizSettings"; interface IntegrationsPageProps { @@ -29,10 +30,42 @@ export const IntegrationsPage = ({ const [isAmoCrmModalOpen, setIsAmoCrmModalOpen] = useState(false); const [isZapierModalOpen, setIsZapierModalOpen] = useState(false); const [isPostbackModalOpen, setIsPostbackModalOpen] = useState(false); + const [leadTargetsLoaded, setLeadTargetsLoaded] = useState(false); + const [leadTargets, setLeadTargets] = useState(null); + const [zapierTarget, setZapierTarget] = useState(null); + const [postbackTarget, setPostbackTarget] = useState(null); + useEffect(() => { if (editQuizId === null) navigate("/list"); }, [navigate, editQuizId]); + + useEffect(() => { + const load = async () => { + if (!leadTargetsLoaded && quiz?.id) { + const [items] = await getLeadTargetsByQuiz(quiz.backendId); + const list = items ?? []; + setLeadTargets(list); + const webhookOnly = list.filter((t) => t.type === "webhook"); + const zapier = webhookOnly.find((t) => (t.target || "").toLowerCase().includes("zapier")) ?? null; + const postback = webhookOnly.find((t) => (t.target || "").toLowerCase().includes("postback")) ?? null; + setZapierTarget(zapier); + setPostbackTarget(postback); + setLeadTargetsLoaded(true); + } + }; + load(); + }, [leadTargetsLoaded, quiz?.id]); + + const refreshLeadTargets = async () => { + if (!quiz?.id) return; + const [items] = await getLeadTargetsByQuiz(quiz.backendId); + const list = items ?? []; + setLeadTargets(list); + const webhookOnly = list.filter((t) => t.type === "webhook"); + setZapierTarget(webhookOnly.find((t) => (t.target || "").toLowerCase().includes("zapier")) ?? null); + setPostbackTarget(webhookOnly.find((t) => (t.target || "").toLowerCase().includes("postback")) ?? null); + }; const heightBar = heightSidebar + 51 + 88 + 36 + 25; if (quiz === undefined) @@ -87,6 +120,8 @@ export const IntegrationsPage = ({ setIsPostbackModalOpen={setIsPostbackModalOpen} isPostbackModalOpen={isPostbackModalOpen} handleClosePostbackModal={handleClosePostbackModal} + zapierTarget={zapierTarget} + postbackTarget={postbackTarget} /> diff --git a/src/pages/IntegrationsPage/PartnersBoard/PartnersBoard.tsx b/src/pages/IntegrationsPage/PartnersBoard/PartnersBoard.tsx index 5217c134..5609aa15 100644 --- a/src/pages/IntegrationsPage/PartnersBoard/PartnersBoard.tsx +++ b/src/pages/IntegrationsPage/PartnersBoard/PartnersBoard.tsx @@ -7,6 +7,7 @@ import { YandexMetricaLogo } from "../mocks/YandexMetricaLogo"; import { VKPixelLogo } from "../mocks/VKPixelLogo"; import { QuizMetricType } from "@model/quizSettings"; import { AmoCRMLogo } from "../mocks/AmoCRMLogo"; +import type { LeadTargetModel } from "@/api/leadtarget"; import { useCurrentQuiz } from "@/stores/quizes/hooks"; const AnalyticsModal = lazy(() => @@ -48,6 +49,8 @@ type PartnersBoardProps = { setIsPostbackModalOpen: (value: boolean) => void; isPostbackModalOpen: boolean; handleClosePostbackModal: () => void; + zapierTarget?: LeadTargetModel | null; + postbackTarget?: LeadTargetModel | null; }; export const PartnersBoard: FC = ({ @@ -65,6 +68,8 @@ export const PartnersBoard: FC = ({ setIsPostbackModalOpen, isPostbackModalOpen, handleClosePostbackModal, + zapierTarget, + postbackTarget, }) => { const theme = useTheme(); const quiz = useCurrentQuiz(); @@ -132,7 +137,7 @@ export const PartnersBoard: FC = ({ /> - {/* + Автоматизация @@ -144,7 +149,7 @@ export const PartnersBoard: FC = ({ setIsModalOpen={setIsPostbackModalOpen} setCompanyName={setCompanyName} /> - */} + {companyName && ( @@ -173,6 +178,7 @@ export const PartnersBoard: FC = ({ handleCloseModal={handleCloseZapierModal} companyName={companyName} quiz={quiz!} + currentTarget={zapierTarget ?? null} /> )} @@ -183,6 +189,7 @@ export const PartnersBoard: FC = ({ handleCloseModal={handleClosePostbackModal} companyName={companyName} quiz={quiz!} + currentTarget={postbackTarget ?? null} /> )} diff --git a/src/pages/IntegrationsPage/hooks/useLeadTargets.ts b/src/pages/IntegrationsPage/hooks/useLeadTargets.ts new file mode 100644 index 00000000..e46c89c6 --- /dev/null +++ b/src/pages/IntegrationsPage/hooks/useLeadTargets.ts @@ -0,0 +1,47 @@ +import { useCallback, useEffect, useState } from "react"; +import { getLeadTargetsByQuiz, LeadTargetModel } from "@/api/leadtarget"; + +type UseLeadTargetsResult = { + isLoading: boolean; + zapierTarget: LeadTargetModel | null; + postbackTarget: LeadTargetModel | null; + refresh: () => Promise; +}; + +export function useLeadTargets(quizBackendId: number | undefined, isOpen: boolean): UseLeadTargetsResult { + const [isLoading, setIsLoading] = useState(false); + const [zapierTarget, setZapierTarget] = useState(null); + const [postbackTarget, setPostbackTarget] = useState(null); + + const load = useCallback(async () => { + if (!quizBackendId) return; + setIsLoading(true); + try { + const [items] = await getLeadTargetsByQuiz(quizBackendId); + const list = items ?? []; + const webhookOnly = list.filter((t) => t.type === "webhook"); + const zapier = webhookOnly.find((t) => (t.target || "").toLowerCase().includes("zapier")) ?? null; + const postback = webhookOnly.find((t) => (t.target || "").toLowerCase().includes("postback")) ?? null; + setZapierTarget(zapier); + setPostbackTarget(postback); + } finally { + setIsLoading(false); + } + }, [quizBackendId]); + + useEffect(() => { + if (isOpen) { + load(); + } + }, [isOpen, load]); + + return { + isLoading, + zapierTarget, + postbackTarget, + refresh: load, + }; +} + + + diff --git a/src/pages/Landing/HeaderLanding.tsx b/src/pages/Landing/HeaderLanding.tsx index 4d30cf6f..ffd9df24 100644 --- a/src/pages/Landing/HeaderLanding.tsx +++ b/src/pages/Landing/HeaderLanding.tsx @@ -2,7 +2,6 @@ import React from "react"; import Box from "@mui/material/Box"; import Button from "@mui/material/Button"; import SectionStyled from "./SectionStyled"; -import NavMenuItem from "@ui_kit/Header/NavMenuItem"; import QuizLogo from "./images/icons/QuizLogo"; import { useMediaQuery, useTheme } from "@mui/material"; import { setIsContactFormOpen } from "../../stores/contactForm"; @@ -26,12 +25,6 @@ export default function Component() { const userId = useUserStore((state) => state.userId); const location = useLocation(); - console.log("HeaderLanding debug:", { - userId, - location: location.pathname, - backgroundLocation: location.state?.backgroundLocation - }); - return ( void; + onEnterKeyPress?: () => void; }; const AnswerItem = memo( - ({ index, variant, questionId, largeCheck = false, additionalContent, additionalMobile, disableKeyDown, isOwn, ownPlaceholder }) => { + ({ index, variant, questionId, largeCheck = false, additionalContent, additionalMobile, disableKeyDown, isOwn, ownPlaceholder, shouldAutoFocus, onFocusHandled, onEnterKeyPress }) => { const theme = useTheme(); const isTablet = useMediaQuery(theme.breakpoints.down(790)); const setOwnPlaceholder = (replText: string) => { - updateQuestion(questionId, (question) => { + updateQuestion(questionId, (question) => { question.content.ownPlaceholder = replText; }); }; + const inputRefCallback = useCallback((element: HTMLInputElement | HTMLTextAreaElement | null) => { + if (element && shouldAutoFocus) { + element.focus(); + onFocusHandled?.(); + } + }, [shouldAutoFocus, onFocusHandled]); + + const handleKeyDown = (event: KeyboardEvent) => { + // Shift+Enter — новая строка, ничего не делаем (даём браузеру вставить перенос) + if (event.key === "Enter" && event.shiftKey) { + return; + } + + // Enter — добавить новый вариант + if (event.key === "Enter") { + event.preventDefault(); + if (disableKeyDown) { + enqueueSnackbar("100 максимальное количество"); + return; + } + if (onEnterKeyPress) { + onEnterKeyPress(); + } else { + // Fallback если onEnterKeyPress не передан + addQuestionVariant(questionId); + } + } + }; + return ( ( }} > ) => { + placeholder={isOwn ? "Добавьте текст-подсказку для ввода \"своего ответа\"" : "Добавьте ответ"} + multiline + rows={1} + onChange={({ target }: ChangeEvent) => { if (target.value.length <= 1000) { isOwn ? setOwnPlaceholder(target.value || " ") @@ -79,13 +113,7 @@ const AnswerItem = memo( enqueueSnackbar("Превышена длина вводимого текста") } }} - onKeyDown={(event: KeyboardEvent) => { - if (disableKeyDown) { - enqueueSnackbar("100 максимальное количество"); - } else if (event.code === "Enter" && !largeCheck) { - addQuestionVariant(questionId); - } - }} + onKeyDown={handleKeyDown} InputProps={{ startAdornment: ( <> @@ -130,6 +158,9 @@ const AnswerItem = memo( }, "& textarea.MuiInputBase-input": { marginTop: "1px", + resize: "none", + // удерживаем стартовую высоту визуально как у однострочного + lineHeight: "21px", }, "& .MuiOutlinedInput-notchedOutline": { border: "none", diff --git a/src/pages/Questions/AnswerDraggableList/ImageEditAnswerItem.tsx b/src/pages/Questions/AnswerDraggableList/ImageEditAnswerItem.tsx index d019b07e..1a5ed5bd 100644 --- a/src/pages/Questions/AnswerDraggableList/ImageEditAnswerItem.tsx +++ b/src/pages/Questions/AnswerDraggableList/ImageEditAnswerItem.tsx @@ -16,6 +16,9 @@ type Props = Omit< openImageUploadModal: () => void; isOwn: boolean; ownPlaceholder: string; + shouldAutoFocus?: boolean; + onFocusHandled?: () => void; + onEnterKeyPress?: () => void; }; export default function ImageEditAnswerItem({ @@ -31,6 +34,9 @@ export default function ImageEditAnswerItem({ openImageUploadModal, isOwn, ownPlaceholder, + shouldAutoFocus, + onFocusHandled, + onEnterKeyPress, }: Props) { const addOrEditImageButton = useMemo(() => { return ( @@ -111,6 +117,9 @@ export default function ImageEditAnswerItem({ additionalMobile={addOrEditImageButtonMobile} isOwn={isOwn} ownPlaceholder={ownPlaceholder} + shouldAutoFocus={shouldAutoFocus} + onFocusHandled={onFocusHandled} + onEnterKeyPress={onEnterKeyPress} /> ); } diff --git a/src/pages/Questions/DropDown/DropDown.tsx b/src/pages/Questions/DropDown/DropDown.tsx index 74969434..e4b8eba0 100644 --- a/src/pages/Questions/DropDown/DropDown.tsx +++ b/src/pages/Questions/DropDown/DropDown.tsx @@ -1,7 +1,7 @@ import { Box, Link, Typography, useMediaQuery, useTheme } from "@mui/material"; import { useCallback, useState } from "react"; import { EnterIcon } from "@/assets/icons/questionsPage/enterIcon"; -import { useAddAnswer } from "../../../utils/hooks/useAddAnswer"; +import { useQuestionVariantsWithFocus } from "../../../utils/questionVariants"; import { AnswerDraggableList } from "../AnswerDraggableList"; import ButtonsOptions from "../QuestionOptions/ButtonsLayout/ButtonsOptions"; import SwitchDropDown from "./switchDropDown"; @@ -16,7 +16,7 @@ interface Props { } export default function DropDown({ question, openBranchingPage, setOpenBranchingPage }: Props) { - const {onClickAddAnAnswer} = useAddAnswer(); + const {addVariantWithFocus, addVariantOnEnter, focusedVariantId, clearFocusedVariant} = useQuestionVariantsWithFocus(); const [switchState, setSwitchState] = useState("setting"); const theme = useTheme(); const isMobile = useMediaQuery(theme.breakpoints.down(790)); @@ -50,51 +50,65 @@ export default function DropDown({ question, openBranchingPage, setOpenBranching disableKeyDown={question.content.variants.length >= 100} questionId={question.id} variant={variant} + isOwn={Boolean(variant?.isOwn)} + ownPlaceholder={""} + shouldAutoFocus={focusedVariantId === variant.id} + onFocusHandled={clearFocusedVariant} + onEnterKeyPress={() => addVariantOnEnter(question.id)} /> ))} /> )} - - onClickAddAnAnswer(question)} - > - Добавьте ответ - + + + addVariantWithFocus(question)} + > + Добавьте ответ + + {isMobile ? null : ( + <> + + или нажмите Enter + + + + )} + {isMobile ? null : ( - <> - - или нажмите Enter - - - + + нажмите shift + enter для переноса строки + )} @@ -105,6 +119,7 @@ export default function DropDown({ question, openBranchingPage, setOpenBranching questionContentId={question.content.id} questionType={question.type} questionHasParent={question.content.rule.parentId?.length !== 0} + openBranchingPage={openBranchingPage} setOpenBranchingPage={setOpenBranchingPage} /> ("setting"); - const {onClickAddAnAnswer} = useAddAnswer(); + const {addVariantWithFocus, addVariantOnEnter, focusedVariantId, clearFocusedVariant} = useQuestionVariantsWithFocus(); const [open, setOpen] = useState(false); const [anchorElement, setAnchorElement] = useState(null); const [selectedVariant, setSelectedVariant] = useState(null); @@ -47,7 +47,10 @@ export default function Emoji({ question, openBranchingPage, setOpenBranchingPag setOpen={setOpen} setSelectedVariant={setSelectedVariant} isOwn={Boolean(variant?.isOwn)} - ownPlaceholder={question.content.ownPlaceholder} + ownPlaceholder={question.content.ownPlaceholder || ""} + shouldAutoFocus={focusedVariantId === variant.id} + onFocusHandled={clearFocusedVariant} + onEnterKeyPress={() => addVariantOnEnter(question.id)} /> ))} /> @@ -81,42 +84,50 @@ export default function Emoji({ question, openBranchingPage, setOpenBranchingPag }} /> - - onClickAddAnAnswer(question)} - > - Добавьте ответ - + + + addVariantWithFocus(question)} + > + Добавьте ответ + + {!isTablet && ( + <> + + или нажмите Enter + + + + )} + {!isTablet && ( - <> - - или нажмите Enter - - - + + для переноса строки нажмите shift + enter + )} @@ -127,6 +138,7 @@ export default function Emoji({ question, openBranchingPage, setOpenBranchingPag questionContentId={question.content.id} questionType={question.type} questionHasParent={question.content.rule.parentId?.length !== 0} + openBranchingPage={openBranchingPage} setOpenBranchingPage={setOpenBranchingPage} /> >; isOwn: boolean; ownPlaceholder: string; + shouldAutoFocus?: boolean; + onFocusHandled?: () => void; + onEnterKeyPress?: () => void; }; export default function EmojiAnswerItem({ @@ -28,6 +31,9 @@ export default function EmojiAnswerItem({ setOpen, isOwn, ownPlaceholder, + shouldAutoFocus, + onFocusHandled, + onEnterKeyPress, }: Props) { @@ -99,6 +105,9 @@ export default function EmojiAnswerItem({ additionalMobile={addOrEditImageButtonMobile} isOwn={isOwn} ownPlaceholder={ownPlaceholder} + shouldAutoFocus={shouldAutoFocus} + onFocusHandled={onFocusHandled} + onEnterKeyPress={onEnterKeyPress} /> ); } diff --git a/src/pages/Questions/Emoji/settingEmoji.tsx b/src/pages/Questions/Emoji/settingEmoji.tsx index 1cff9275..84371e2d 100644 --- a/src/pages/Questions/Emoji/settingEmoji.tsx +++ b/src/pages/Questions/Emoji/settingEmoji.tsx @@ -4,7 +4,7 @@ import { updateQuestion } from "@root/questions/actions"; import CustomCheckbox from "@ui_kit/CustomCheckbox"; import { memo } from "react"; import CustomTextField from "@ui_kit/CustomTextField"; -import { useAddAnswer } from "@/utils/hooks/useAddAnswer"; +import { useQuestionVariantsWithFocus } from "@/utils/questionVariants"; type SettingEmojiProps = { question: QuizQuestionEmoji; @@ -17,7 +17,7 @@ type SettingEmojiProps = { const SettingEmoji = memo(function ({ question, questionId, isRequired, isLargeCheck, isMulti, isOwn }) { const theme = useTheme(); - const {switchOwn} = useAddAnswer(); + const {switchOwnVariant} = useQuestionVariantsWithFocus(); const isWrappColumn = useMediaQuery(theme.breakpoints.down(980)); const isFigmaTablte = useMediaQuery(theme.breakpoints.down(990)); @@ -92,7 +92,7 @@ const SettingEmoji = memo(function ({ question, questionId, i label={'Вариант "свой ответ"'} checked={isOwn} handleChange={({ target }) => { - switchOwn({question, checked:target.checked}) + switchOwnVariant({question, checked:target.checked}) }} /> {/* diff --git a/src/pages/Questions/QuestionOptions/OptionsAndPicture/OptionsAndPicture.tsx b/src/pages/Questions/QuestionOptions/OptionsAndPicture/OptionsAndPicture.tsx index 6dfc6dc8..9bc7c8ba 100644 --- a/src/pages/Questions/QuestionOptions/OptionsAndPicture/OptionsAndPicture.tsx +++ b/src/pages/Questions/QuestionOptions/OptionsAndPicture/OptionsAndPicture.tsx @@ -1,9 +1,9 @@ import { Box, Link, Typography, useMediaQuery, useTheme } from "@mui/material"; import { - addQuestionVariant, clearQuestionImages, uploadQuestionImage, } from "@root/questions/actions"; +import { useQuestionVariantsWithFocus } from "@/utils/questionVariants"; import { useCurrentQuiz } from "@root/quizes/hooks"; import { useEffect, useMemo, useState } from "react"; import { EnterIcon } from "@/assets/icons/questionsPage/enterIcon"; @@ -31,6 +31,7 @@ export default function OptionsAndPicture({ const [switchState, setSwitchState] = useState("setting"); const [pictureUploding, setPictureUploading] = useState(false); const [openCropModal, setOpenCropModal] = useState(false); + const {addVariantWithFocus, addVariantOnEnter, focusedVariantId, clearFocusedVariant} = useQuestionVariantsWithFocus(); const [selectedVariantId, setSelectedVariantId] = useState( null, @@ -111,6 +112,9 @@ export default function OptionsAndPicture({ setSelectedVariantId={setSelectedVariantId} isOwn={Boolean(variant?.isOwn)} ownPlaceholder={question.content.ownPlaceholder} + shouldAutoFocus={focusedVariantId === variant.id} + onFocusHandled={clearFocusedVariant} + onEnterKeyPress={() => addVariantOnEnter(question.id)} /> ))} /> @@ -130,49 +134,58 @@ export default function OptionsAndPicture({ selfClose={() => setOpenCropModal(false)} setPictureUploading={setPictureUploading} /> - - { - addQuestionVariant(question.id); - }} - > - Добавьте ответ - + + + { + addVariantWithFocus(question); + }} + > + Добавьте ответ + + {isMobile ? null : ( + <> + + или нажмите Enter + + + + )} + {isMobile ? null : ( - <> - - или нажмите Enter - - - + + для переноса строки нажмите shift + enter + )} diff --git a/src/pages/Questions/QuestionOptions/OptionsAndPicture/SettingOptionsAndPict.tsx b/src/pages/Questions/QuestionOptions/OptionsAndPicture/SettingOptionsAndPict.tsx index 7cb9f70f..c38ee7ed 100644 --- a/src/pages/Questions/QuestionOptions/OptionsAndPicture/SettingOptionsAndPict.tsx +++ b/src/pages/Questions/QuestionOptions/OptionsAndPicture/SettingOptionsAndPict.tsx @@ -1,4 +1,4 @@ -import { useAddAnswer } from "@/utils/hooks/useAddAnswer"; +import { useQuestionVariantsWithFocus } from "@/utils/questionVariants"; import type { QuizQuestionVarImg, QuizQuestionVariant } from "@frontend/squzanswerer"; import { Box, Typography, useMediaQuery, useTheme } from "@mui/material"; import { updateQuestion } from "@root/questions/actions"; @@ -19,7 +19,7 @@ type SettingOptionsAndPictProps = { const SettingOptionsAndPict = memo(function ({ question, questionId, ownPlaceholder, isMulti, isLargeCheck, replText, isRequired, isOwn }) { const theme = useTheme(); - const { switchOwn } = useAddAnswer(); + const { switchOwnVariant } = useQuestionVariantsWithFocus(); const isWrappColumn = useMediaQuery(theme.breakpoints.down(980)); const isFigmaTablte = useMediaQuery(theme.breakpoints.down(990)); @@ -73,7 +73,7 @@ const SettingOptionsAndPict = memo(function ({ quest label={'Вариант "свой ответ"'} checked={isOwn} handleChange={({ target }) => { - switchOwn({ question, checked: target.checked }) + switchOwnVariant({ question, checked: target.checked }) }} /> (false); const [openCropModal, setOpenCropModal] = useState(false); @@ -87,12 +87,15 @@ export default function OptionsPicture({ largeCheck={question.content.largeCheck} variant={variant} isMobile={isMobile} - openCropModal={() => {setOpenCropModal(true)}} + openCropModal={() => { setOpenCropModal(true); return Promise.resolve(); }} openImageUploadModal={openImageUploadModal} pictureUploding={pictureUploding} setSelectedVariantId={setSelectedVariantId} isOwn={Boolean(variant?.isOwn)} - ownPlaceholder={question.content.ownPlaceholder} + ownPlaceholder={question.content.ownPlaceholder || ""} + shouldAutoFocus={focusedVariantId === variant.id} + onFocusHandled={clearFocusedVariant} + onEnterKeyPress={() => addVariantOnEnter(question.id)} /> ))} /> @@ -102,45 +105,60 @@ export default function OptionsPicture({ handleImageChange={handleImageUpload} /> setOpenCropModal(false)} - setPictureUploading={setPictureUploading} + setPictureUploading={setPictureUploading as any} /> - - onClickAddAnAnswer(question)} - > - Добавьте ответ - + + + addVariantWithFocus(question)} + > + Добавьте ответ + + {isMobile ? null : ( + <> + + или нажмите Enter + + + + )} + {isMobile ? null : ( - <> - - или нажмите Enter - - - + + для переноса строки нажмите shift + enter + )} @@ -151,11 +169,12 @@ export default function OptionsPicture({ questionContentId={question.content.id} questionType={question.type} questionHasParent={question.content.rule.parentId?.length !== 0} + openBranchingPage={openBranchingPage} setOpenBranchingPage={setOpenBranchingPage} /> ); diff --git a/src/pages/Questions/QuestionOptions/OptionsPicture/settingOptionsPict.tsx b/src/pages/Questions/QuestionOptions/OptionsPicture/settingOptionsPict.tsx index 26cfd18a..07a36db6 100644 --- a/src/pages/Questions/QuestionOptions/OptionsPicture/settingOptionsPict.tsx +++ b/src/pages/Questions/QuestionOptions/OptionsPicture/settingOptionsPict.tsx @@ -9,7 +9,7 @@ import ProportionsIcon11 from "@/assets/icons/questionsPage/ProportionsIcon11"; import ProportionsIcon12 from "@/assets/icons/questionsPage/ProportionsIcon12"; import ProportionsIcon21 from "@/assets/icons/questionsPage/ProportionsIcon21"; import CustomTextField from "@ui_kit/CustomTextField"; -import { useAddAnswer } from "@/utils/hooks/useAddAnswer"; +import { useQuestionVariantsWithFocus } from "@/utils/questionVariants"; type Proportion = "1:1" | "1:2" | "2:1"; @@ -69,7 +69,7 @@ const SettingOptionsPict = memo(function ({ question.content.ownPlaceholder = replText; }); }; - const {switchOwn} = useAddAnswer(); + const {switchOwnVariant} = useQuestionVariantsWithFocus(); return ( (function ({ label={'Вариант "свой ответ"'} checked={isOwn} handleChange={({ target }) => { - switchOwn({question, checked:target.checked}) + switchOwnVariant({question, checked:target.checked}) }} /> {/* diff --git a/src/pages/Questions/QuestionOptions/OptionsPicture/settingOpytionsPict.tsx b/src/pages/Questions/QuestionOptions/OptionsPicture/settingOpytionsPict.tsx index 26cfd18a..07a36db6 100644 --- a/src/pages/Questions/QuestionOptions/OptionsPicture/settingOpytionsPict.tsx +++ b/src/pages/Questions/QuestionOptions/OptionsPicture/settingOpytionsPict.tsx @@ -9,7 +9,7 @@ import ProportionsIcon11 from "@/assets/icons/questionsPage/ProportionsIcon11"; import ProportionsIcon12 from "@/assets/icons/questionsPage/ProportionsIcon12"; import ProportionsIcon21 from "@/assets/icons/questionsPage/ProportionsIcon21"; import CustomTextField from "@ui_kit/CustomTextField"; -import { useAddAnswer } from "@/utils/hooks/useAddAnswer"; +import { useQuestionVariantsWithFocus } from "@/utils/questionVariants"; type Proportion = "1:1" | "1:2" | "2:1"; @@ -69,7 +69,7 @@ const SettingOptionsPict = memo(function ({ question.content.ownPlaceholder = replText; }); }; - const {switchOwn} = useAddAnswer(); + const {switchOwnVariant} = useQuestionVariantsWithFocus(); return ( (function ({ label={'Вариант "свой ответ"'} checked={isOwn} handleChange={({ target }) => { - switchOwn({question, checked:target.checked}) + switchOwnVariant({question, checked:target.checked}) }} /> {/* diff --git a/src/pages/Questions/QuestionOptions/answerOptions/AnswerOptions.tsx b/src/pages/Questions/QuestionOptions/answerOptions/AnswerOptions.tsx index 5f55e6c5..5af6dcbe 100755 --- a/src/pages/Questions/QuestionOptions/answerOptions/AnswerOptions.tsx +++ b/src/pages/Questions/QuestionOptions/answerOptions/AnswerOptions.tsx @@ -2,7 +2,7 @@ import { Box, Link, Typography, useMediaQuery, useTheme } from "@mui/material"; import { useEffect, useState } from "react"; import { EnterIcon } from "@/assets/icons/questionsPage/enterIcon"; import type { QuizQuestionVariant } from "@frontend/squzanswerer"; -import { useAddAnswer } from "@/utils/hooks/useAddAnswer"; +import { useQuestionVariantsWithFocus } from "@/utils/questionVariants"; import { AnswerDraggableList } from "../../AnswerDraggableList"; import AnswerItem from "../../AnswerDraggableList/AnswerItem"; import ButtonsOptions from "../ButtonsLayout/ButtonsOptions"; @@ -15,7 +15,7 @@ interface Props { } export default function AnswerOptions({ question, openBranchingPage, setOpenBranchingPage }: Props) { - const {onClickAddAnAnswer} = useAddAnswer(); + const {addVariantWithFocus, addVariantOnEnter, focusedVariantId, clearFocusedVariant} = useQuestionVariantsWithFocus(); const [switchState, setSwitchState] = useState("setting"); const theme = useTheme(); const isMobile = useMediaQuery(theme.breakpoints.down(790)); @@ -54,53 +54,65 @@ export default function AnswerOptions({ question, openBranchingPage, setOpenBran questionId={question.id} variant={variant} isOwn={Boolean(variant.isOwn)} - ownPlaceholder={question.content.ownPlaceholder} + ownPlaceholder={question.content.ownPlaceholder || ""} + shouldAutoFocus={focusedVariantId === variant.id} + onFocusHandled={clearFocusedVariant} + onEnterKeyPress={() => addVariantOnEnter(question.id)} /> ))} /> )} - - onClickAddAnAnswer(question)} - > - Добавьте ответ - + + + addVariantWithFocus(question)} + > + Добавьте ответ + + {isMobile ? null : ( + <> + + или нажмите Enter + + + + )} + {isMobile ? null : ( - <> - - или нажмите Enter - - - + + для переноса строки нажмите shift + enter + )} diff --git a/src/pages/Questions/QuestionOptions/answerOptions/responseSettings.tsx b/src/pages/Questions/QuestionOptions/answerOptions/responseSettings.tsx index 655024ef..548fe64f 100644 --- a/src/pages/Questions/QuestionOptions/answerOptions/responseSettings.tsx +++ b/src/pages/Questions/QuestionOptions/answerOptions/responseSettings.tsx @@ -4,7 +4,7 @@ import CustomCheckbox from "@ui_kit/CustomCheckbox"; import type { QuizQuestionVariant } from "@frontend/squzanswerer"; import { memo } from "react"; import CustomTextField from "@ui_kit/CustomTextField"; -import { useAddAnswer } from "@/utils/hooks/useAddAnswer"; +import { useQuestionVariantsWithFocus } from "@/utils/questionVariants"; interface Props { question: QuizQuestionVariant; @@ -21,7 +21,7 @@ const ResponseSettings = memo(function ({question, questionId, ownPlaceho const isTablet = useMediaQuery(theme.breakpoints.down(900)); const isFigmaTablte = useMediaQuery(theme.breakpoints.down(990)); const isMobile = useMediaQuery(theme.breakpoints.down(790)); - const {switchOwn} = useAddAnswer(); + const {switchOwnVariant} = useQuestionVariantsWithFocus(); return ( (function ({question, questionId, ownPlaceho label={'Вариант "свой ответ"'} checked={isOwn} handleChange={({ target }) => { - switchOwn({question, checked:target.checked}) + switchOwnVariant({question, checked:target.checked}) }} /> diff --git a/src/pages/auth/RecoverPassword.tsx b/src/pages/auth/RecoverPassword.tsx index 0d08753d..33a5c9d7 100644 --- a/src/pages/auth/RecoverPassword.tsx +++ b/src/pages/auth/RecoverPassword.tsx @@ -55,7 +55,6 @@ export default function RecoverPassword() { initialValues, validationSchema, onSubmit: async (values, formikHelpers) => { - console.log("tokenUser", tokenUser); if (tokenUser) { setAuthToken(tokenUser || ""); @@ -77,11 +76,8 @@ export default function RecoverPassword() { }, }); useEffect(() => { - console.log("RecoverPassword useEffect - window.location.search:", window.location.search); - console.log("RecoverPassword useEffect - window.location.href:", window.location.href); const params = new URLSearchParams(window.location.search); const authToken = params.get("auth"); - console.log("RecoverPassword useEffect - authToken:", authToken); setTokenUser(authToken || ""); history.pushState(null, document.title, "/changepwd"); diff --git a/src/pages/startPage/EditPage.tsx b/src/pages/startPage/EditPage.tsx index 89dabfc4..053ec38a 100755 --- a/src/pages/startPage/EditPage.tsx +++ b/src/pages/startPage/EditPage.tsx @@ -22,7 +22,7 @@ import { ModalInfoWhyCantCreate } from "./ModalInfoWhyCantCreate"; import { ConfirmLeaveModal } from "./ConfirmLeaveModal"; import { checkQuestionHint } from "@utils/checkQuestionHint"; import AmoTokenExpiredDialog from "../IntegrationsPage/IntegrationsModal/Amo/AmoTokenExpiredDialog"; -import { useAmoAccount } from "@/api/integration"; +import pinkScrollbar from "@utils/pinkScrollbar"; interface Props { openBranchingPage: boolean; @@ -121,6 +121,7 @@ export default function EditPage({ }} > {quizConfig && ( diff --git a/src/pages/startPage/StartPageSettings/StartPageSettings.tsx b/src/pages/startPage/StartPageSettings/StartPageSettings.tsx index 6ea822f6..eb23982c 100755 --- a/src/pages/startPage/StartPageSettings/StartPageSettings.tsx +++ b/src/pages/startPage/StartPageSettings/StartPageSettings.tsx @@ -22,6 +22,7 @@ import { MenuItem, Select, Skeleton, + CircularProgress, Tooltip, Typography, useMediaQuery, @@ -35,7 +36,7 @@ import QuestionTimerSettings from "./QuestionTimerSettings"; import SelectableButton from "@ui_kit/SelectableButton"; import { StartPagePreview } from "@ui_kit/StartPagePreview"; import { resizeFavIcon } from "@ui_kit/reactImageFileResizer"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import { createPortal } from "react-dom"; import FaviconDropZone from "../FaviconDropZone"; import ModalSizeImage from "../ModalSizeImage"; @@ -69,23 +70,37 @@ export default function StartPageSettings() { if (!quiz) return null; + // Диагностика видимости скелетона загрузки видео + useEffect(() => { + console.log("[StartPage] backgroundUploding state:", backgroundUploding, { + hasVideo: Boolean(quiz?.config.startpage.background.video), + type: quiz?.config.startpage.background.type, + }); + }, [backgroundUploding, quiz?.config.startpage.background.video, quiz?.config.startpage.background.type]); + async function handleVideoUpload(videoUrl: string) { if (!quiz) return; - setBackgroundUploading(true); - + console.log("[StartPage] Video upload start", { videoUrl }); if (videoUrl.startsWith("blob:")) { + setBackgroundUploading(true); const videoBlob = await (await fetch(videoUrl)).blob(); + console.log("[StartPage] Uploading blob to backend", { size: videoBlob.size, type: videoBlob.type }); uploadQuizImage(quiz.id, videoBlob, (quiz, url) => { quiz.config.startpage.background.video = url; + console.log("[StartPage] Backend returned video URL", { url }); }); } else { + // для ссылок скелетон не нужен + setBackgroundUploading(false); updateQuiz(quiz.id, (quiz) => { quiz.config.startpage.background.video = videoUrl; }); + console.log("[StartPage] Set external video URL", { videoUrl }); } - setBackgroundUploading(false); + // Не сбрасываем backgroundUploding здесь — ждём onLoaded от VideoElement + console.log("[StartPage] Waiting for video preview to load..."); } const designType = quiz?.config?.startpageType; @@ -376,14 +391,7 @@ export default function StartPageSettings() { setBackgroundUploading(false); }} - onImageSaveClick={async (file) => { - setBackgroundUploading(true); - await uploadQuizImage(quiz.id, file, (quiz, url) => { - quiz.config.startpage.background.desktop = url; - }); - - setBackgroundUploading(false); - }} + onDeleteClick={() => { updateQuiz(quiz.id, (quiz) => { quiz.config.startpage.background.desktop = null; @@ -433,11 +441,12 @@ export default function StartPageSettings() { {backgroundUploding ? ( ) : ( @@ -465,16 +474,37 @@ export default function StartPageSettings() { )} ) : ( - + { + console.log('[StartPage] VideoElement reported loaded'); + setBackgroundUploading(false); + }} + showSkeleton={backgroundUploding} onDeleteClick={() => { updateQuiz(quiz.id, (quiz) => { quiz.config.startpage.background.video = null; }); }} /> + {backgroundUploding && ( + t.zIndex.modal + 1, + bgcolor: (t) => t.palette.action.hover, + pointerEvents: "none", + }} + /> + )} )} @@ -570,14 +600,7 @@ export default function StartPageSettings() { setLogoUploading(false); }} - onImageSaveClick={async (file) => { - setLogoUploading(true); - await uploadQuizImage(quiz.id, file, (quiz, url) => { - quiz.config.startpage.logo = url; - }); - - setLogoUploading(false); - }} + onDeleteClick={() => { updateQuiz(quiz.id, (quiz) => { quiz.config.startpage.logo = null; @@ -650,14 +673,7 @@ export default function StartPageSettings() { setLogoUploading(false); }} - onImageSaveClick={async (file) => { - setLogoUploading(true); - await uploadQuizImage(quiz.id, file, (quiz, url) => { - quiz.config.startpage.logo = url; - }); - - setLogoUploading(false); - }} + onDeleteClick={() => { updateQuiz(quiz.id, (quiz) => { quiz.config.startpage.logo = null; diff --git a/src/pages/startPage/VideoElement.tsx b/src/pages/startPage/VideoElement.tsx index 5be6618a..0908ea10 100644 --- a/src/pages/startPage/VideoElement.tsx +++ b/src/pages/startPage/VideoElement.tsx @@ -1,5 +1,6 @@ import Box from "@mui/material/Box"; -import { FC } from "react"; +import { FC, useEffect, useRef, useState } from "react"; +import Skeleton from "@mui/material/Skeleton"; import DeleteIcon from "@mui/icons-material/Delete"; import { IconButton, SxProps, Theme } from "@mui/material"; import { QuizVideo } from "@frontend/squzanswerer"; @@ -10,6 +11,8 @@ type VideoElementProps = { theme: Theme; onDeleteClick: () => void; deleteIconSx?: SxProps; + onLoaded?: () => void; + showSkeleton?: boolean; }; export const VideoElement: FC = ({ @@ -18,10 +21,62 @@ export const VideoElement: FC = ({ theme, onDeleteClick, deleteIconSx, + onLoaded, + showSkeleton = true, }) => { + const containerRef = useRef(null); + const [isLoaded, setIsLoaded] = useState(false); + + useEffect(() => { + console.log("[VideoElement] init for", { videoSrc }); + setIsLoaded(false); + + const container = containerRef.current; + if (!container) return; + + const attach = (videoEl: HTMLVideoElement | null) => { + if (!videoEl) return; + console.log("[VideoElement] attach video element", videoEl); + const markLoaded = (e?: Event) => { + console.log("[VideoElement] markLoaded via", e?.type, { readyState: (videoEl as any).readyState }); + setIsLoaded(true); + try { onLoaded && onLoaded(); } catch (err) { console.warn('[VideoElement] onLoaded error', err); } + }; + if (typeof (videoEl as any).readyState === "number" && (videoEl as any).readyState >= 2) { + console.log("[VideoElement] already loaded", { readyState: (videoEl as any).readyState }); + setIsLoaded(true); + return; + } + videoEl.addEventListener("loadeddata", markLoaded, { once: true }); + videoEl.addEventListener("canplay", markLoaded, { once: true }); + videoEl.addEventListener("loadedmetadata", markLoaded, { once: true }); + }; + + const tryAttach = () => { + const videoEl = container.querySelector("video"); + attach(videoEl as HTMLVideoElement | null); + }; + + tryAttach(); + + const observer = new MutationObserver((mutations) => { + console.log("[VideoElement] mutations", mutations.map(m => m.type)); + tryAttach(); + }); + observer.observe(container, { childList: true, subtree: true, attributes: true }); + + return () => observer.disconnect(); + }, [videoSrc]); + return ( - + + {!isLoaded && showSkeleton && ( + + )} ( requestQueue.enqueue(`updateQuestion-${questionId}`, request); }; -export const addQuestionVariant = (questionId: string) => { +export const addQuestionVariant = (questionId: string): string => { + const newVariant = createQuestionVariant(); updateQuestion(questionId, (question) => { switch (question.type) { case "variant": @@ -335,12 +336,13 @@ export const addQuestionVariant = (questionId: string) => { case "select": case "images": case "varimg": - question.content.variants.push(createQuestionVariant()); + question.content.variants.push(newVariant); break; default: throw new Error(`Cannot add variant to question of type "${question.type}"`); } }); + return newVariant.id; }; export const addQuestionOwnVariant = (questionId: string) => { updateQuestion(questionId, (question) => { diff --git a/src/stores/questions/hooks.ts b/src/stores/questions/hooks.ts index ed7a0f8b..90075e06 100644 --- a/src/stores/questions/hooks.ts +++ b/src/stores/questions/hooks.ts @@ -8,13 +8,20 @@ import { useQuestionsStore } from "./store"; import { useCurrentQuiz } from "@root/quizes/hooks"; import { useEffect } from "react"; -export function useQuestions() { - const quiz = useCurrentQuiz(); - const { isLoading, error, isValidating } = useSWR( - ["questions", quiz?.backendId], +export function useQuestions({ quizId }: { quizId?: number } = {}) { + const currentQuiz = useCurrentQuiz(); + const currentQuizId = quizId ?? currentQuiz?.backendId; + + const { data, isLoading, error, isValidating } = useSWR( + currentQuizId ? ["questions", currentQuizId] : null, ([, id]) => questionApi.getList({ quiz_id: id }), { - onSuccess: ([questions]) => setQuestions(questions), + onSuccess: (data) => { + // Добавляем проверку на существование данных + if (data && Array.isArray(data[0])) { + setQuestions(data[0]); + } + }, onError: (error) => { const message = isAxiosError(error) ? error.response?.data ?? "" @@ -25,7 +32,13 @@ export function useQuestions() { }, }, ); + const questions = useQuestionsStore((state) => state.questions); - return { questions, isLoading, error, isValidating }; + return { + questions: questions || [], // Гарантируем возврат массива + isLoading, + error, + isValidating + }; } diff --git a/src/stores/user.ts b/src/stores/user.ts index f626f241..408ea0e9 100644 --- a/src/stores/user.ts +++ b/src/stores/user.ts @@ -67,16 +67,12 @@ export const setUser = (user: User) => ); export const clearUserData = () => { - console.log("clearUserData: Clearing user data"); - console.log("clearUserData: Before clearing -", useUserStore.getState()); useUserStore.setState({ ...initialState }); - console.log("clearUserData: After clearing -", useUserStore.getState()); // Также очищаем localStorage напрямую localStorage.removeItem("user"); - console.log("clearUserData: localStorage cleared"); }; export const setUserAccount = (userAccount: OriginalUserAccount) => diff --git a/src/ui_kit/CustomTextField.tsx b/src/ui_kit/CustomTextField.tsx index 75621450..f50e47d6 100755 --- a/src/ui_kit/CustomTextField.tsx +++ b/src/ui_kit/CustomTextField.tsx @@ -17,12 +17,12 @@ interface CustomTextFieldProps { value?: string; error?: string; emptyError?: boolean; - onChange?: (event: ChangeEvent) => void; - onKeyDown?: (event: KeyboardEvent) => void; - onBlur?: (event: FocusEvent) => void; - onFocus?: (event: FocusEvent) => void; - onClick?: (event: MouseEvent) => void; - onPaste?: (event: ClipboardEvent) => void; + onChange?: (event: ChangeEvent) => void; + onKeyDown?: (event: KeyboardEvent) => void; + onBlur?: (event: FocusEvent) => void; + onFocus?: (event: FocusEvent) => void; + onClick?: (event: MouseEvent) => void; + onPaste?: (event: ClipboardEvent) => void; text?: string; maxLength?: number; sx?: SxProps; @@ -32,7 +32,7 @@ interface CustomTextFieldProps { rows?: number; className?: string; disabled?: boolean; - inputRef?: Ref; + inputRef?: Ref; } export default function CustomTextField({ @@ -67,7 +67,9 @@ export default function CustomTextField({ setInputValue(value); }, [value]); - const handleInputChange = (event: React.ChangeEvent) => { + const handleInputChange = ( + event: React.ChangeEvent + ) => { if (event.target.value.length <= maxLength) { const inputValue = event.target.value; @@ -85,12 +87,16 @@ export default function CustomTextField({ } }; - const handleInputFocus = (event: React.FocusEvent) => { + const handleInputFocus = ( + event: React.FocusEvent + ) => { setIsInputActive(true); if (onFocus) onFocus(event); }; - const handleInputBlur = (event: React.FocusEvent) => { + const handleInputBlur = ( + event: React.FocusEvent + ) => { setIsInputActive(false); if (onBlur) { @@ -98,6 +104,12 @@ export default function CustomTextField({ } }; + const mergedInputElementProps = { + ...(InputProps?.inputProps as any), + onClick, + onPaste, + }; + return ( 0} - rows={rows} + multiline + rows={rows > 0 ? rows : 1} disabled={disabled} disableUnderline inputRef={inputRef} {...InputProps} + inputProps={mergedInputElementProps} sx={{ maxLength: maxLength, borderRadius: "10px", @@ -143,6 +154,8 @@ export default function CustomTextField({ border: `${isInputActive ? "black 2px" : "#9A9AAF 1px"} solid`, backgroundColor: theme.palette.background.default, height: "48px", + // Prevent resize handle to keep visuals unchanged + '& textarea': { resize: 'none' }, ...sx, }} data-cy="textfield" diff --git a/src/ui_kit/MediaSelectionAndDisplay.tsx b/src/ui_kit/MediaSelectionAndDisplay.tsx index 6e73289c..6620167f 100644 --- a/src/ui_kit/MediaSelectionAndDisplay.tsx +++ b/src/ui_kit/MediaSelectionAndDisplay.tsx @@ -68,14 +68,17 @@ export const MediaSelectionAndDisplay: FC = ({ } async function handleVideoUpload(videoUrl: string) { + console.log("[QuestionMedia] Video upload start", { videoUrl }); setBackgroundUploading(true); if (videoUrl.startsWith("blob:")) { const videoBlob = await (await fetch(videoUrl)).blob(); + console.log("[QuestionMedia] Uploading blob to backend", { size: videoBlob.size, type: videoBlob.type }); uploadQuestionImage(question.id, quizQid, videoBlob, (question, url) => { if (!("video" in question.content)) return; question.content.video = url; + console.log("[QuestionMedia] Backend returned video URL", { url }); }); } else { updateQuestion(question.id, (question) => { @@ -83,9 +86,10 @@ export const MediaSelectionAndDisplay: FC = ({ question.content.video = videoUrl; }); + console.log("[QuestionMedia] Set external video URL", { videoUrl }); } - setTimeout(() => {setBackgroundUploading(false)},7000); + setTimeout(() => { setBackgroundUploading(false); console.log("[QuestionMedia] Video upload end"); }, 7000); } return ( diff --git a/src/ui_kit/PrivateRoute.tsx b/src/ui_kit/PrivateRoute.tsx index 27c4503d..a82f522f 100644 --- a/src/ui_kit/PrivateRoute.tsx +++ b/src/ui_kit/PrivateRoute.tsx @@ -6,26 +6,15 @@ export default function PrivateRoute() { const user = useUserStore((state) => state.user); const userId = useUserStore((state) => state.userId); - console.log("PrivateRoute debug:", { - user: user ? "exists" : "null", - userId: user?._id, - userIdFromStore: userId, - currentPath: window.location.pathname, - userObject: user - }); - useEffect(() => { if (!user) { - console.log("PrivateRoute: User is null, redirecting to / via useEffect"); window.location.href = "/"; } }, [user]); if (!user) { - console.log("PrivateRoute: User is null, showing fallback"); return <>; } - console.log("PrivateRoute: User exists, rendering Outlet"); return ; } diff --git a/src/utils/generateHubWalletRequest.ts b/src/utils/generateHubWalletRequest.ts index b7f6bfdb..d8e2fcf1 100644 --- a/src/utils/generateHubWalletRequest.ts +++ b/src/utils/generateHubWalletRequest.ts @@ -17,8 +17,6 @@ export const generateHubWalletRequestURL = ({ }) => { let currentDomain = window.location.host; if (currentDomain === "localhost") currentDomain += ":3000"; - -console.log("Я здесь для отладки и спешу сообщить, что деплой был успешно завершен!") // Используем более надежный способ генерации URL const baseUrl = `http://${isTestServer ? "s" : ""}hub.pena.digital/anyservicepayment`; diff --git a/src/utils/handleComponentError.ts b/src/utils/handleComponentError.ts index 2687d868..907b98a1 100644 --- a/src/utils/handleComponentError.ts +++ b/src/utils/handleComponentError.ts @@ -25,10 +25,8 @@ export function handleComponentError(error: Error, info: ErrorInfo, getTickets: if (!getAuthToken()) return; // Проверяем разрешение на отправку ошибок (по домену) if (!isErrorReportingAllowed(error)) { - console.log('❌ Отправка ошибки заблокирована:', error.message); return; } - console.log(`✅ Обработка ошибки: ${error.message}`); // Копируем __forceSend если есть const componentError: ComponentError & { __forceSend?: boolean } = { timestamp: Math.floor(Date.now() / 1000), @@ -56,7 +54,6 @@ export async function sendErrorsToServer(getTickets: () => Ticket[]) { // Если хотя бы одна ошибка в очереди с __forceSend, отправляем всё const forceSend = errorsQueue.some(e => (e as any).__forceSend); if (!forceSend && !isErrorReportingAllowed()) { - console.log('❌ Отправка ошибок заблокирована, очищаем очередь'); errorsQueue = []; return; } @@ -93,10 +90,8 @@ export async function sendErrorsToServer(getTickets: () => Ticket[]) { // Ищет существующий тикет с system: true export async function findSystemTicket(tickets: Ticket[]) { for (const ticket of tickets) { - console.log("[findSystemTicket] Проверяем тикет:", ticket); if (!('messages' in ticket)) { if (ticket.top_message && ticket.top_message.system === true) { - console.log("[findSystemTicket] Найден тикет по top_message.system:true:", ticket.id); return ticket.id; } } diff --git a/src/utils/hooks/useAddAnswer.ts b/src/utils/hooks/useAddAnswer.ts deleted file mode 100644 index 0c8ee048..00000000 --- a/src/utils/hooks/useAddAnswer.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { QuizQuestionsWithVariants } from "@frontend/squzanswerer"; -import { addQuestionOwnVariant, addQuestionVariant, updateQuestion } from "@root/questions/actions"; - -export const useAddAnswer = () => { - const onClickAddAnAnswer = (question: QuizQuestionsWithVariants) => { - addQuestionVariant(question.id); - }; - interface SwitchOwnProps { - question: QuizQuestionsWithVariants; - checked: boolean - } - const switchOwn = ({ question, checked }: SwitchOwnProps) => { - if (!question.content.variants.some(v => v.isOwn) && checked) { - addQuestionOwnVariant(question.id) - } - - updateQuestion(question.id, (question) => { - question.content.own = checked; - }); - } - - return { - onClickAddAnAnswer, - switchOwn - }; -}; diff --git a/src/utils/hooks/useAnalytics.ts b/src/utils/hooks/useAnalytics.ts index 7cb80665..30c0ea5d 100644 --- a/src/utils/hooks/useAnalytics.ts +++ b/src/utils/hooks/useAnalytics.ts @@ -25,36 +25,47 @@ export function useAnalytics({ ready, quizId, to, from }: useAnalyticsProps) { const [devices, setDevices] = useState(null); const [general, setGeneral] = useState(null); const [questions, setQuestions] = useState(null); + const [isLoading, setIsLoading] = useState(true); useEffect(() => { - if (!quizId || !ready) return; + if (!quizId || !ready) { + setIsLoading(true); + return; + } const requestStatistics = async () => { if (!formatTo || !formatFrom) { + setIsLoading(true); return; } - const [gottenGeneral] = await getGeneral(quizId, formatTo, formatFrom); - const [gottenDevices] = await getDevices(quizId, formatTo, formatFrom); - const [gottenQuestions] = await getQuestions(quizId, formatTo, formatFrom); + setIsLoading(true); - getGraphics(quizId, formatTo, formatFrom) + try { + const [gottenGeneral] = await getGeneral(quizId, formatTo, formatFrom); + const [gottenDevices] = await getDevices(quizId, formatTo, formatFrom); + const [gottenQuestions] = await getQuestions(quizId, formatTo, formatFrom); - if (gottenGeneral) { - setGeneral(gottenGeneral); - } + getGraphics(quizId, formatTo, formatFrom); - if (gottenDevices) { - setDevices(gottenDevices); - } + if (gottenGeneral) { + setGeneral(gottenGeneral); + } - if (gottenQuestions) { - setQuestions(gottenQuestions); + if (gottenDevices) { + setDevices(gottenDevices); + } + + if (gottenQuestions) { + setQuestions(gottenQuestions); + } + } finally { + setIsLoading(false); } }; requestStatistics(); }, [ready, quizId, to, from]); - return { devices, general, questions }; + return { devices, general, questions, isLoading }; } diff --git a/src/utils/hooks/useAutoPay.ts b/src/utils/hooks/useAutoPay.ts index 6b2a0855..e28649dc 100644 --- a/src/utils/hooks/useAutoPay.ts +++ b/src/utils/hooks/useAutoPay.ts @@ -35,21 +35,10 @@ export const useAfterPay = () => { // Проверяем, есть ли токен восстановления пароля в URL const hasAuthToken = searchParams.get("auth") || window.location.search.includes("auth="); - console.log("useAfterPay debug:", { - pathname: location.pathname, - backgroundLocation: location.state?.backgroundLocation, - isRecoverPasswordModal, - searchParams: window.location.search, - authToken: searchParams.get("auth"), - hasAuthToken - }); // НЕ очищаем параметры на странице восстановления пароля, когда открыты модалки или есть токен auth if (location.pathname !== "/changepwd" && !location.state?.backgroundLocation && !isRecoverPasswordModal && !hasAuthToken) { - console.log("Очищаем параметры URL"); setSearchParams({}, { replace: true }); - } else { - console.log("НЕ очищаем параметры URL"); } if (userId && URLuserId && userId === URLuserId) { diff --git a/src/utils/pinkScrollbar.ts b/src/utils/pinkScrollbar.ts new file mode 100644 index 00000000..4247218a --- /dev/null +++ b/src/utils/pinkScrollbar.ts @@ -0,0 +1,13 @@ +import { Theme } from "@mui/material"; + +export default (theme: Theme) => ({ + scrollbarWidth: "auto", + "&::-webkit-scrollbar": { + display: "block", + width: "8px", + }, + "&::-webkit-scrollbar-thumb": { + backgroundColor: theme.palette.brightPurple.main, + borderRadius: "4px", + } +}); \ No newline at end of file diff --git a/src/utils/questionVariants.ts b/src/utils/questionVariants.ts new file mode 100644 index 00000000..398d0892 --- /dev/null +++ b/src/utils/questionVariants.ts @@ -0,0 +1,47 @@ +import { QuizQuestionsWithVariants, QuizQuestionVariant } from "@frontend/squzanswerer"; +import { addQuestionOwnVariant, addQuestionVariant, updateQuestion } from "@root/questions/actions"; +import { useState } from "react"; + +/** + * Утилита для управления вариантами ответов с автофокусом + */ +export const useQuestionVariantsWithFocus = () => { + const [focusedVariantId, setFocusedVariantId] = useState(null); + + const addVariantWithFocus = (question: QuizQuestionsWithVariants) => { + const newVariantId = addQuestionVariant(question.id); + setFocusedVariantId(newVariantId); + }; + + const addVariantOnEnter = (questionId: string) => { + const newVariantId = addQuestionVariant(questionId); + setFocusedVariantId(newVariantId); + }; + + const clearFocusedVariant = () => { + setFocusedVariantId(null); + }; + + interface SwitchOwnProps { + question: QuizQuestionsWithVariants; + checked: boolean + } + + const switchOwnVariant = ({ question, checked }: SwitchOwnProps) => { + if (!question.content.variants.some(v => v.isOwn) && checked) { + addQuestionOwnVariant(question.id) + } + + updateQuestion(question.id, (question) => { + question.content.own = checked; + }); + } + + return { + addVariantWithFocus, + addVariantOnEnter, + switchOwnVariant, + focusedVariantId, + clearFocusedVariant + }; +};