Merge branch 'staging'

This commit is contained in:
Nastya 2025-10-06 01:06:13 +03:00
commit 7e9613d975
50 changed files with 1507 additions and 489 deletions

@ -1,2 +0,0 @@
1.0.1 Страница заявок корректно отображает мультиответ
1.0.0 Добавлены фичи "мультиответ", "перенос строки в своём ответе", "свой ответ", "плейсхолдер своего ответа"

19
.husky/pre-commit Normal file → Executable file

@ -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

@ -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}"

@ -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 Добавлены фичи "мультиответ", "перенос строки в своём ответе", "свой ответ", "плейсхолдер своего ответа"

101
src/api/leadtarget.ts Normal file

@ -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<unknown, LeadTargetModel[]>({
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<typeof body, LeadTargetModel | true>({
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<typeof body, LeadTargetModel>({
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<unknown, unknown>({
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,
};

@ -0,0 +1,13 @@
import { Box, SxProps } from "@mui/material";
export default function OrangeYoutube(sx: SxProps) {
return (
<Box
sx={sx}
>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M19.9756 4.36328C21.0799 4.36346 21.9754 5.25898 21.9756 6.36328V17.6367C21.9754 18.741 21.0799 19.6365 19.9756 19.6367H4.02734C2.92289 19.6367 2.02754 18.7411 2.02734 17.6367V6.36328C2.02754 5.25887 2.92289 4.36328 4.02734 4.36328H19.9756ZM10.2227 8.18262C10.1533 8.17891 10.0838 8.19583 10.0225 8.23145C9.9614 8.26705 9.9107 8.32013 9.875 8.38477C9.83921 8.44959 9.81946 8.52397 9.81934 8.59961V15.4004C9.81946 15.476 9.83921 15.5504 9.875 15.6152C9.9107 15.6799 9.9614 15.7329 10.0225 15.7686C10.0838 15.8042 10.1533 15.8211 10.2227 15.8174C10.292 15.8137 10.3592 15.79 10.417 15.748L15.1025 12.3477C15.1552 12.3095 15.1986 12.258 15.2285 12.1973C15.2584 12.1366 15.2744 12.0689 15.2744 12C15.2744 11.9311 15.2584 11.8634 15.2285 11.8027C15.1986 11.742 15.1552 11.6905 15.1025 11.6523L10.417 8.25195C10.3592 8.21003 10.292 8.18633 10.2227 8.18262Z" fill="#FA590B" />
</svg>
</Box>
);
}

@ -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<Quiz>({} as Quiz);
const quiz = useCurrentQuiz();
const globalQuestions = useQuestions({quizId: quiz?.backendId}).questions;
const [isOpen, setOpen] = useState<boolean>(false);
const [isOpenEnd, setOpenEnd] = useState<boolean>(false);
const [from, setFrom] = useState<Moment | null>(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<void> => {
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 ? <ResetIcon /> : "Сбросить"}
</Button>
</Box>
<General
data={general}
day={86400 - moment(to).unix() - moment(from).unix() > 0}
/>
<AnswersStatistics data={questions} />
<Devices data={devices} />
{isLoading ? (
<AnalyticsSkeleton />
) : (
<>
<General
data={general}
day={86400 - moment(to).unix() - moment(from).unix() > 0}
/>
<AnswersStatistics data={questions} globalQuestions={globalQuestions}/>
<Devices data={devices} />
</>
)}
</SectionWrapper>
</>
);

@ -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 = (
<Paper
sx={{
overflow: "hidden",
borderRadius: "12px",
boxShadow: "0 0 20px rgba(0, 0, 0, 0.15)",
padding: "20px",
}}
>
<Skeleton variant="text" sx={{ width: "40%", height: 28, mb: 1 }} />
<Skeleton variant="text" sx={{ width: "20%", height: 24, mb: 2 }} />
<Skeleton variant="rectangular" sx={{ width: "100%", height: 220, borderRadius: "8px" }} />
</Paper>
);
return (
<Box>
<Box sx={{ marginTop: "45px" }}>
<Typography component="h3" sx={{ fontSize: "24px", fontWeight: "bold", color: theme.palette.text.primary }}>
Ключевые метрики
</Typography>
<Box
sx={{
display: "grid",
gridTemplateColumns: isTablet ? (isMobile ? "1fr" : "1fr 1fr") : "1fr 1fr 1fr",
gap: "20px",
marginTop: "40px",
}}
>
{card}
{card}
{card}
{card}
</Box>
</Box>
<Box sx={{ marginTop: "120px" }}>
<Typography component="h3" sx={{ fontSize: "24px", fontWeight: "bold", color: theme.palette.text.primary }}>
Статистика по ответам
</Typography>
<Box sx={{ display: isTablet && !isMobile ? "flex" : "block", gap: "40px", mt: "20px" }}>
<Paper sx={{ borderRadius: "12px", boxShadow: "0 0 20px rgba(0, 0, 0, 0.15)", p: 2, mb: 2 }}>
<Skeleton variant="text" sx={{ width: "60%", height: 28, mb: 1 }} />
<Skeleton variant="rectangular" sx={{ width: "100%", height: 260, borderRadius: "8px" }} />
</Paper>
<Paper sx={{ borderRadius: "12px", boxShadow: "0 0 20px rgba(0, 0, 0, 0.15)", p: 2 }}>
<Skeleton variant="text" sx={{ width: "60%", height: 28, mb: 1 }} />
<Skeleton variant="rectangular" sx={{ width: "100%", height: 260, borderRadius: "8px" }} />
</Paper>
</Box>
<Box sx={{ mt: "30px" }}>
<Skeleton variant="text" sx={{ width: 300, height: 28, mb: 2 }} />
<Paper sx={{ borderRadius: "12px", boxShadow: "0 0 20px rgba(0, 0, 0, 0.15)", p: 2 }}>
{[...Array(4)].map((_, idx) => (
<Box key={idx} sx={{ p: "15px 25px" }}>
<Skeleton variant="rectangular" sx={{ width: "100%", height: 44, borderRadius: "10px" }} />
</Box>
))}
</Paper>
</Box>
</Box>
<Box sx={{ marginTop: "120px" }}>
<Typography component="h3" sx={{ fontSize: "24px", fontWeight: "bold", color: theme.palette.text.primary }}>
Статистика пользователей
</Typography>
<Box
sx={{
display: "grid",
gridTemplateColumns: isTablet ? (isMobile ? "1fr" : "1fr 1fr") : "1fr 1fr 1fr",
gap: "20px",
marginTop: "30px",
}}
>
{[...Array(3)].map((_, i) => (
<Paper key={i} sx={{ overflow: "hidden", minHeight: "500px", display: "flex", flexDirection: "column", gap: "30px", borderRadius: "12px", boxShadow: "0 0 20px rgba(0, 0, 0, 0.15)", p: 2 }}>
<Skeleton variant="text" sx={{ width: "50%", height: 28 }} />
<Skeleton variant="circular" width={245} height={245} sx={{ alignSelf: "center" }} />
<Box sx={{ background: theme.palette.background.default, padding: "20px" }}>
{[...Array(4)].map((_, idx) => (
<Box key={idx} sx={{ display: "flex", mb: "10px" }}>
<Skeleton variant="text" sx={{ flexGrow: 1, height: 20 }} />
<Skeleton variant="text" sx={{ width: 60, height: 20, ml: 2 }} />
</Box>
))}
</Box>
</Paper>
))}
</Box>
</Box>
</Box>
);
}

@ -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<string, Record<string, number>> | 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 (
<Box sx={{ padding: "15px 25px" }}>
<Box sx={{ padding: "15px 25px", width: "100%" }}>
<Box
sx={{
position: "relative",
@ -200,7 +210,8 @@ const Pagination = ({ page, setPage, pagesAmount }: PaginationProps) => {
);
};
export const Answers: FC<AnswersProps> = ({ data }) => {
export const Answers: FC<AnswersProps> = ({ data, globalQuestions }) => {
const quiz = useCurrentQuiz();
const [page, setPage] = useState<number>(1);
const theme = useTheme();
const answers = useMemo(() => {
@ -210,17 +221,32 @@ export const Answers: FC<AnswersProps> = ({ 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<AnswersProps> = ({ data }) => {
<NextIcon />
</ButtonBase> */}
</Box>
{currentAnswerExtended.map(([title, percent], index) => (
<Answer
key={index}
title={title}
percent={percent}
highlight={!index}
/>
))}
</Paper>
{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 (
<Box
sx={{
display: "inline - flex",
width: "100 %",
alignItems: "center",
padding: "0 48px",
paddingLeft: "26px"
}}
>
<IconButton
// <IconButton target="_blank" href={`/image/${title}`}
sx={{
width: "50px",
height: "40px",
bgcolor: "#e9eaf0",
borderRadius: "5px"
}}
onClick={() => {
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);
}}
>
<InsertDriveFileIcon />
</IconButton>
< Answer
key={index}
title={title}
percent={percent}
highlight={!index}
/>
</Box>
)
} else {
return (
< Answer
key={index}
title={title}
percent={percent}
highlight={!index}
/>
)
}
})}
</Paper >
<Pagination page={page} setPage={setPage} pagesAmount={answers.length} />
</Box>
</Box >
);
};
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;
}

@ -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<AnswersStatisticsProps> = ({ data }) => {
export const AnswersStatistics: FC<AnswersStatisticsProps> = ({ 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<AnswersStatisticsProps> = ({ data }) => {
gap: "40px",
}}
>
<Answers data={data?.Questions || null} />
<Answers data={data?.Questions || null} globalQuestions={globalQuestions} />
<Funnel data={data?.Funnel || []} funnelData={data?.FunnelData || []} />
</Box>
<Results data={data?.Results || null} />

@ -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<GeneralProps> = ({ 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,
);

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

@ -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)
}}
>
<Box sx={{ display: "flex", gap: "20px", flexWrap: "wrap" }}>
@ -199,3 +192,4 @@ export const DesignFilling = ({
</Box>
);
};

@ -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<InstructionYoutubeLinkProps> = ({ sx, ...props }) => {
return (
<Box
sx={{
display: "flex",
justifyContent: "right",
...sx,
}}
{...props}
>
<Link
href="https://youtube.com"
underline="hover"
sx={{
color: "#FA590B",
display: "inline-flex",
gap: "10px",
fontSize: "16px"
}}
>
<OrangeYoutube sx={{
width: "24px",
height: "24px",
}} /> Смотреть инструкцию
</Link>
</Box>
);
};
export default InstructionYoutubeLink;

@ -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<PostbackModalProps> = ({
isModalOpen,
handleCloseModal,
companyName,
quiz
export const PostbackModal: FC<PostbackModalProps> = ({
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<boolean>(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 (
<Dialog
@ -28,47 +93,103 @@ export const PostbackModal: FC<PostbackModalProps> = ({
PaperProps={{
sx: {
maxWidth: isTablet ? "100%" : "919px",
height: "658px",
height: isMobile ? "303px" : "214px",
// height: "314px",
borderRadius: "12px",
},
}}
>
<Box>
<Box
<Box
sx={{
width: "100%",
height: "68px",
backgroundColor: theme.palette.background.default,
display: "flex",
justifyContent: "space-between",
alignItems: "center",
padding: "0 20px",
}}
>
<Typography
sx={{
width: "100%",
height: "68px",
backgroundColor: theme.palette.background.default,
display: "flex",
justifyContent: "space-between",
alignItems: "center",
padding: "0 20px",
fontSize: isMobile ? "20px" : "24px",
fontWeight: "500",
color: theme.palette.grey2.main,
}}
>
<Typography
Автоматизация с {companyName ? companyName : "Postback"}
</Typography>
<IconButton onClick={handleCloseModal}>
<CloseIcon />
</IconButton>
</Box>
<Box
sx={{
padding: "20px",
height: "calc(100% - 68px)",
overflow: "auto",
}}
>
{!isMobile && <InstructionYoutubeLink />}
<Box
component="form"
onSubmit={formik.handleSubmit}
sx={{
marginTop: isMobile ? 0 : "-43px",
display: isMobile ? "flex" : "inline-flex",
flexDirection: isMobile ? "column" : "row",
alignItems: isMobile ? "center" : "end",
gap: isMobile ? "10px" : "38px",
width: "100%"
}}
>
<Box
sx={{
fontSize: isMobile ? "20px" : "24px",
fontWeight: "500",
color: theme.palette.grey2.main,
width: "100%"
}}
>
Интеграция с {companyName ? companyName : "Postback"}
</Typography>
<IconButton onClick={handleCloseModal}>
<CloseIcon />
</IconButton>
</Box>
<Box
sx={{
padding: "20px",
height: "calc(100% - 68px)",
overflow: "auto",
}}
>
<Typography variant="body1">
Интеграция с Postback находится в разработке.
</Typography>
<Typography
sx={{
fontWeight: 500,
color: "black",
mt: isMobile ? 0 : "11px",
mb: "14px",
}}
>
Домен
</Typography>
{isLoading ? (
<Box sx={{ width: "100%", height: 44, borderRadius: "8px", bgcolor: "action.hover" }} />
) : (
<CustomTextField
id="postback-domain"
placeholder="токен в формате ХХХХХХ"
value={formik.values.domain}
onChange={(e) => formik.setFieldValue("domain", e.target.value)}
maxLength={150}
/>
)}
</Box>
<Button
disabled={isSaving || isLoading}
type="submit"
variant="contained"
sx={{
backgroundColor: "#7E2AEA",
fontSize: "18px",
lineHeight: "18px",
width: isMobile ? "100%" : "216px",
height: "44px",
p: "10px 20px",
}}
>
Сохранить
</Button>
</Box>
{isMobile && <InstructionYoutubeLink sx={{ mt: "40px", justifyContent: "center" }} />}
</Box>
</Dialog>
);

@ -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<ZapierModalProps> = ({
isModalOpen,
handleCloseModal,
companyName,
quiz
export const ZapierModal: FC<ZapierModalProps> = ({
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<boolean>(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 (
<Dialog
@ -28,7 +95,7 @@ export const ZapierModal: FC<ZapierModalProps> = ({
PaperProps={{
sx: {
maxWidth: isTablet ? "100%" : "919px",
height: "658px",
height: isMobile ? "303px" : "195px",
borderRadius: "12px",
},
}}
@ -52,12 +119,13 @@ export const ZapierModal: FC<ZapierModalProps> = ({
color: theme.palette.grey2.main,
}}
>
Интеграция с {companyName ? companyName : "Zapier"}
Автоматизация с {companyName ? companyName : "Zapier"}
</Typography>
<IconButton onClick={handleCloseModal}>
<CloseIcon />
</IconButton>
</Box>
<Box
sx={{
padding: "20px",
@ -65,9 +133,74 @@ export const ZapierModal: FC<ZapierModalProps> = ({
overflow: "auto",
}}
>
<Typography variant="body1">
Интеграция с Zapier находится в разработке.
</Typography>
{!isMobile && <InstructionYoutubeLink />}
<Box
sx={{
marginTop: isMobile ? 0 : "-33px",
display: isMobile ? "flex" : "block",
flexDirection: "column",
alignItems: "center"
}}
>
<Box
sx={{
width: "100%"
}}
>
<Typography
sx={{
fontWeight: 500,
color: "black",
mt: isMobile ? 0 : "11px",
mb: "14px",
}}
>
URL webhook
</Typography>
</Box>
<Box
component="form"
onSubmit={formik.handleSubmit}
sx={{
display: "inline-flex",
flexDirection: isMobile ? "column" : "row",
width: "100%",
gap: isMobile ? "10px" : "38px"
}}
>
{isLoading ? (
<Box sx={{ width: "100%", height: 44, borderRadius: "8px", bgcolor: "action.hover" }} />
) : (
<CustomTextField
id="zapier-webhook-url"
placeholder="введите url здесь"
value={formik.values.webhookUrl}
onChange={(e) => formik.setFieldValue("webhookUrl", e.target.value)}
maxLength={150}
/>
)}
<Button
disabled={isSaving || isLoading}
type="submit"
variant="contained"
sx={{
backgroundColor: "#7E2AEA",
fontSize: "18px",
lineHeight: "18px",
width: isMobile ? "100%" : "216px",
height: "44px",
p: "10px 20px",
}}
>
Сохранить
</Button>
</Box>
{isMobile && <InstructionYoutubeLink sx={{ mt: "40px" }} />}
</Box>
</Box>
</Box>
</Dialog>

@ -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<boolean>(false);
const [isZapierModalOpen, setIsZapierModalOpen] = useState<boolean>(false);
const [isPostbackModalOpen, setIsPostbackModalOpen] = useState<boolean>(false);
const [leadTargetsLoaded, setLeadTargetsLoaded] = useState<boolean>(false);
const [leadTargets, setLeadTargets] = useState<LeadTargetModel[] | null>(null);
const [zapierTarget, setZapierTarget] = useState<LeadTargetModel | null>(null);
const [postbackTarget, setPostbackTarget] = useState<LeadTargetModel | null>(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}
/>
</Box>
</>

@ -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<PartnersBoardProps> = ({
@ -65,6 +68,8 @@ export const PartnersBoard: FC<PartnersBoardProps> = ({
setIsPostbackModalOpen,
isPostbackModalOpen,
handleClosePostbackModal,
zapierTarget,
postbackTarget,
}) => {
const theme = useTheme();
const quiz = useCurrentQuiz();
@ -132,7 +137,7 @@ export const PartnersBoard: FC<PartnersBoardProps> = ({
/>
</Box>
{/* <Typography variant="h6" sx={sectionTitleStyles}>
<Typography variant="h6" sx={sectionTitleStyles}>
Автоматизация
</Typography>
<Box sx={containerStyles}>
@ -144,7 +149,7 @@ export const PartnersBoard: FC<PartnersBoardProps> = ({
setIsModalOpen={setIsPostbackModalOpen}
setCompanyName={setCompanyName}
/>
</Box> */}
</Box>
</Box>
{companyName && (
@ -173,6 +178,7 @@ export const PartnersBoard: FC<PartnersBoardProps> = ({
handleCloseModal={handleCloseZapierModal}
companyName={companyName}
quiz={quiz!}
currentTarget={zapierTarget ?? null}
/>
</Suspense>
)}
@ -183,6 +189,7 @@ export const PartnersBoard: FC<PartnersBoardProps> = ({
handleCloseModal={handleClosePostbackModal}
companyName={companyName}
quiz={quiz!}
currentTarget={postbackTarget ?? null}
/>
</Suspense>
)}

@ -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<void>;
};
export function useLeadTargets(quizBackendId: number | undefined, isOpen: boolean): UseLeadTargetsResult {
const [isLoading, setIsLoading] = useState<boolean>(false);
const [zapierTarget, setZapierTarget] = useState<LeadTargetModel | null>(null);
const [postbackTarget, setPostbackTarget] = useState<LeadTargetModel | null>(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,
};
}

@ -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 (
<SectionStyled
tag={"header"}

@ -12,7 +12,7 @@ import {
} from "@mui/material";
import { addQuestionVariant, deleteQuestionVariant, setQuestionVariantField, updateQuestion } from "@root/questions/actions";
import { enqueueSnackbar } from "notistack";
import { memo, type ChangeEvent, type FC, type KeyboardEvent, type ReactNode } from "react";
import { memo, useCallback, type ChangeEvent, type FC, type KeyboardEvent, type ReactNode } from "react";
import { Draggable } from "react-beautiful-dnd";
import type { QuestionVariant, QuizQuestionVariant } from "@frontend/squzanswerer";
@ -28,19 +28,51 @@ type AnswerItemProps = {
additionalMobile?: ReactNode;
isOwn: boolean;
ownPlaceholder: string;
shouldAutoFocus?: boolean;
onFocusHandled?: () => void;
onEnterKeyPress?: () => void;
};
const AnswerItem = memo<AnswerItemProps>(
({ 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<QuizQuestionVariant>(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<HTMLDivElement>) => {
// 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 (
<Draggable
@ -64,12 +96,14 @@ const AnswerItem = memo<AnswerItemProps>(
}}
>
<TextField
inputRef={inputRefCallback}
value={isOwn ? ownPlaceholder : variant.answer}
fullWidth
focused={false}
placeholder={isOwn ? "Добавьте текст-подсказку для ввода “своего ответа”" : "Добавьте ответ"}
multiline={largeCheck}
onChange={({ target }: ChangeEvent<HTMLInputElement>) => {
placeholder={isOwn ? "Добавьте текст-подсказку для ввода \"своего ответа\"" : "Добавьте ответ"}
multiline
rows={1}
onChange={({ target }: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
if (target.value.length <= 1000) {
isOwn ?
setOwnPlaceholder(target.value || " ")
@ -79,13 +113,7 @@ const AnswerItem = memo<AnswerItemProps>(
enqueueSnackbar("Превышена длина вводимого текста")
}
}}
onKeyDown={(event: KeyboardEvent<HTMLInputElement>) => {
if (disableKeyDown) {
enqueueSnackbar("100 максимальное количество");
} else if (event.code === "Enter" && !largeCheck) {
addQuestionVariant(questionId);
}
}}
onKeyDown={handleKeyDown}
InputProps={{
startAdornment: (
<>
@ -130,6 +158,9 @@ const AnswerItem = memo<AnswerItemProps>(
},
"& textarea.MuiInputBase-input": {
marginTop: "1px",
resize: "none",
// удерживаем стартовую высоту визуально как у однострочного
lineHeight: "21px",
},
"& .MuiOutlinedInput-notchedOutline": {
border: "none",

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

@ -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)}
/>
))}
/>
)}
<Box
sx={{
display: "flex",
alignItems: "center",
marginBottom: "20px",
}}
>
<Link
component="button"
variant="body2"
sx={{
color: theme.palette.brightPurple.main,
fontWeight: "400",
fontSize: "16px",
mr: "4px",
height: "19px",
}}
onClick={() => onClickAddAnAnswer(question)}
>
Добавьте ответ
</Link>
<Box sx={{ display: "flex", flexDirection: "column", alignItems: "flex-start", marginBottom: "20px" }}>
<Box sx={{ display: "flex", alignItems: "center" }}>
<Link
component="button"
variant="body2"
sx={{
color: theme.palette.brightPurple.main,
fontWeight: "400",
fontSize: "16px",
mr: "4px",
height: "19px",
}}
onClick={() => addVariantWithFocus(question)}
>
Добавьте ответ
</Link>
{isMobile ? null : (
<>
<Typography
sx={{
fontWeight: 400,
lineHeight: "21.33px",
color: theme.palette.grey2.main,
fontSize: "16px",
}}
>
или нажмите Enter
</Typography>
<EnterIcon
style={{
color: "#7E2AEA",
fontSize: "24px",
marginLeft: "6px",
}}
/>
</>
)}
</Box>
{isMobile ? null : (
<>
<Typography
sx={{
fontWeight: 400,
lineHeight: "21.33px",
color: theme.palette.grey2.main,
fontSize: "16px",
}}
>
или нажмите Enter
</Typography>
<EnterIcon
style={{
color: "#7E2AEA",
fontSize: "24px",
marginLeft: "6px",
}}
/>
</>
<Typography
sx={{
fontWeight: 400,
lineHeight: "21.33px",
color: theme.palette.grey2.main,
fontSize: "16px",
mt: "4px",
}}
>
нажмите shift + enter для переноса строки
</Typography>
)}
</Box>
</Box>
@ -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}
/>
<SwitchDropDown

@ -4,7 +4,7 @@ import { EmojiPicker } from "@ui_kit/EmojiPicker";
import { useState } from "react";
import { EnterIcon } from "@/assets/icons/questionsPage/enterIcon";
import type { QuizQuestionEmoji } from "@frontend/squzanswerer";
import { useAddAnswer } from "../../../utils/hooks/useAddAnswer";
import { useQuestionVariantsWithFocus } from "../../../utils/questionVariants";
import { AnswerDraggableList } from "../AnswerDraggableList";
import ButtonsOptions from "../QuestionOptions/ButtonsLayout/ButtonsOptions";
import EmojiAnswerItem from "./EmojiAnswerItem/EmojiAnswerItem";
@ -19,7 +19,7 @@ interface Props {
export default function Emoji({ question, openBranchingPage, setOpenBranchingPage }: Props) {
const [switchState, setSwitchState] = useState<string>("setting");
const {onClickAddAnAnswer} = useAddAnswer();
const {addVariantWithFocus, addVariantOnEnter, focusedVariantId, clearFocusedVariant} = useQuestionVariantsWithFocus();
const [open, setOpen] = useState<boolean>(false);
const [anchorElement, setAnchorElement] = useState<HTMLDivElement | null>(null);
const [selectedVariant, setSelectedVariant] = useState<string | null>(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
}}
/>
</Popover>
<Box
sx={{
display: "flex",
alignItems: "center",
gap: "10px",
marginBottom: isMobile ? "17px" : "20px",
}}
>
<Link
component="button"
variant="body2"
sx={{ color: theme.palette.brightPurple.main }}
onClick={() => onClickAddAnAnswer(question)}
>
Добавьте ответ
</Link>
<Box sx={{ display: "flex", flexDirection: "column", alignItems: "flex-start", marginBottom: isMobile ? "17px" : "20px" }}>
<Box sx={{ display: "flex", alignItems: "center" }}>
<Link
component="button"
variant="body2"
sx={{ color: theme.palette.brightPurple.main }}
onClick={() => addVariantWithFocus(question)}
>
Добавьте ответ
</Link>
{!isTablet && (
<>
<Typography
sx={{
fontWeight: 400,
lineHeight: "21.33px",
color: theme.palette.grey2.main,
fontSize: "16px",
}}
>
или нажмите Enter
</Typography>
<EnterIcon
style={{
color: "#7E2AEA",
fontSize: "24px",
marginLeft: "6px",
}}
/>
</>
)}
</Box>
{!isTablet && (
<>
<Typography
sx={{
fontWeight: 400,
lineHeight: "21.33px",
color: theme.palette.grey2.main,
fontSize: "16px",
}}
>
или нажмите Enter
</Typography>
<EnterIcon
style={{
color: "#7E2AEA",
fontSize: "24px",
marginLeft: "6px",
}}
/>
</>
<Typography
sx={{
fontWeight: 400,
lineHeight: "21.33px",
color: theme.palette.grey2.main,
fontSize: "16px",
mt: "4px",
}}
>
для переноса строки нажмите shift + enter
</Typography>
)}
</Box>
</Box>
@ -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}
/>
<SwitchEmoji

@ -14,6 +14,9 @@ type Props = Omit<
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
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}
/>
);
}

@ -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<SettingEmojiProps>(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<SettingEmojiProps>(function ({ question, questionId, i
label={'Вариант "свой ответ"'}
checked={isOwn}
handleChange={({ target }) => {
switchOwn({question, checked:target.checked})
switchOwnVariant({question, checked:target.checked})
}}
/>
{/* <Box sx={{ mt: isMobile ? "11px" : "6px", width: "100%" }}>

@ -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<boolean>(false);
const [openCropModal, setOpenCropModal] = useState(false);
const {addVariantWithFocus, addVariantOnEnter, focusedVariantId, clearFocusedVariant} = useQuestionVariantsWithFocus();
const [selectedVariantId, setSelectedVariantId] = useState<string | null>(
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}
/>
<Box
sx={{
display: "flex",
alignItems: "center",
marginBottom: "17px",
}}
>
<Link
component="button"
variant="body2"
sx={{
color: theme.palette.brightPurple.main,
fontWeight: "400",
fontSize: "16px",
mr: "4px",
height: "19px",
}}
onClick={() => {
addQuestionVariant(question.id);
}}
>
Добавьте ответ
</Link>
<Box sx={{ display: "flex", flexDirection: "column", alignItems: "flex-start", marginBottom: "17px" }}>
<Box sx={{ display: "flex", alignItems: "center" }}>
<Link
component="button"
variant="body2"
sx={{
color: theme.palette.brightPurple.main,
fontWeight: "400",
fontSize: "16px",
mr: "4px",
height: "19px",
}}
onClick={() => {
addVariantWithFocus(question);
}}
>
Добавьте ответ
</Link>
{isMobile ? null : (
<>
<Typography
sx={{
fontWeight: 400,
lineHeight: "21.33px",
color: theme.palette.grey2.main,
fontSize: "16px",
}}
>
или нажмите Enter
</Typography>
<EnterIcon
style={{
color: "#7E2AEA",
fontSize: "24px",
marginLeft: "6px",
}}
/>
</>
)}
</Box>
{isMobile ? null : (
<>
<Typography
sx={{
fontWeight: 400,
lineHeight: "21.33px",
color: theme.palette.grey2.main,
fontSize: "16px",
}}
>
или нажмите Enter
</Typography>
<EnterIcon
style={{
color: "#7E2AEA",
fontSize: "24px",
marginLeft: "6px",
}}
/>
</>
<Typography
sx={{
fontWeight: 400,
lineHeight: "21.33px",
color: theme.palette.grey2.main,
fontSize: "16px",
mt: "4px",
}}
>
для переноса строки нажмите shift + enter
</Typography>
)}
</Box>
</Box>

@ -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<SettingOptionsAndPictProps>(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<SettingOptionsAndPictProps>(function ({ quest
label={'Вариант "свой ответ"'}
checked={isOwn}
handleChange={({ target }) => {
switchOwn({ question, checked: target.checked })
switchOwnVariant({ question, checked: target.checked })
}}
/>
<CustomCheckbox

@ -9,7 +9,7 @@ import { EnterIcon } from "@/assets/icons/questionsPage/enterIcon";
import type { QuizQuestionVarImg } from "@frontend/squzanswerer/dist-package/model/questionTypes/varimg";
//@/model/questionTypes/images";
import { useAddAnswer } from "@/utils/hooks/useAddAnswer";
import { useQuestionVariantsWithFocus } from "@/utils/questionVariants";
import { useDisclosure } from "@/utils/useDisclosure";
import { AnswerDraggableList } from "../../AnswerDraggableList";
import ImageEditAnswerItem from "../../AnswerDraggableList/ImageEditAnswerItem";
@ -31,7 +31,7 @@ export default function OptionsPicture({
setOpenBranchingPage,
}: Props) {
const theme = useTheme();
const {onClickAddAnAnswer} = useAddAnswer();
const {addVariantWithFocus, addVariantOnEnter, focusedVariantId, clearFocusedVariant} = useQuestionVariantsWithFocus();
const quizQid = useCurrentQuiz()?.qid;
const [pictureUploding, setPictureUploading] = useState<boolean>(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}
/>
<CropModalInit
originalImageUrl={variant?.originalImageUrl}
editedUrlImagesList={variant?.editedUrlImagesList}
originalImageUrl={variant?.originalImageUrl ?? ""}
editedUrlImagesList={(variant?.editedUrlImagesList as any) ?? undefined}
questionId={question.id.toString()}
questionType={question.type}
quizId={quizQid}
variantId={variant?.id}
questionType={question.type as any}
quizId={quizQid ?? ""}
variantId={variant?.id ?? ""}
open={openCropModal}
selfClose={() => setOpenCropModal(false)}
setPictureUploading={setPictureUploading}
setPictureUploading={setPictureUploading as any}
/>
<Box sx={{ display: "flex", alignItems: "center", gap: "10px" }}>
<Link
component="button"
variant="body2"
sx={{ color: theme.palette.brightPurple.main }}
onClick={() => onClickAddAnAnswer(question)}
>
Добавьте ответ
</Link>
<Box sx={{ display: "flex", flexDirection: "column", alignItems: "flex-start" }}>
<Box sx={{ display: "flex", alignItems: "center" }}>
<Link
component="button"
variant="body2"
sx={{ color: theme.palette.brightPurple.main }}
onClick={() => addVariantWithFocus(question)}
>
Добавьте ответ
</Link>
{isMobile ? null : (
<>
<Typography
sx={{
fontWeight: 400,
lineHeight: "21.33px",
color: theme.palette.grey2.main,
fontSize: "16px",
}}
>
или нажмите Enter
</Typography>
<EnterIcon
style={{
color: "#7E2AEA",
fontSize: "24px",
marginLeft: "6px",
}}
/>
</>
)}
</Box>
{isMobile ? null : (
<>
<Typography
sx={{
fontWeight: 400,
lineHeight: "21.33px",
color: theme.palette.grey2.main,
fontSize: "16px",
}}
>
или нажмите Enter
</Typography>
<EnterIcon
style={{
color: "#7E2AEA",
fontSize: "24px",
marginLeft: "6px",
}}
/>
</>
<Typography
sx={{
fontWeight: 400,
lineHeight: "21.33px",
color: theme.palette.grey2.main,
fontSize: "16px",
mt: "4px",
}}
>
для переноса строки нажмите shift + enter
</Typography>
)}
</Box>
</Box>
@ -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}
/>
<SwitchAnswerOptionsPict
switchState={switchState}
question={question}
question={question as any}
/>
</>
);

@ -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<SettingOptionsPictProps>(function ({
question.content.ownPlaceholder = replText;
});
};
const {switchOwn} = useAddAnswer();
const {switchOwnVariant} = useQuestionVariantsWithFocus();
return (
<Box
@ -175,7 +175,7 @@ const SettingOptionsPict = memo<SettingOptionsPictProps>(function ({
label={'Вариант "свой ответ"'}
checked={isOwn}
handleChange={({ target }) => {
switchOwn({question, checked:target.checked})
switchOwnVariant({question, checked:target.checked})
}}
/>
{/* <Box sx={{ mt: isMobile ? "11px" : "6px", width: "100%" }}>

@ -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<SettingOptionsPictProps>(function ({
question.content.ownPlaceholder = replText;
});
};
const {switchOwn} = useAddAnswer();
const {switchOwnVariant} = useQuestionVariantsWithFocus();
return (
<Box
@ -175,7 +175,7 @@ const SettingOptionsPict = memo<SettingOptionsPictProps>(function ({
label={'Вариант "свой ответ"'}
checked={isOwn}
handleChange={({ target }) => {
switchOwn({question, checked:target.checked})
switchOwnVariant({question, checked:target.checked})
}}
/>
{/* <Box sx={{ mt: isMobile ? "11px" : "6px", width: "100%" }}>

@ -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)}
/>
))}
/>
)}
<Box
sx={{
display: "flex",
alignItems: "center",
marginBottom: "17px",
}}
>
<Link
component="button"
variant="body2"
sx={{
color: theme.palette.brightPurple.main,
fontWeight: "400",
fontSize: "16px",
mr: "4px",
height: "19px",
}}
onClick={() => onClickAddAnAnswer(question)}
>
Добавьте ответ
</Link>
<Box sx={{ display: "flex", flexDirection: "column", alignItems: "flex-start", marginBottom: "17px" }}>
<Box sx={{ display: "flex", alignItems: "center" }}>
<Link
component="button"
variant="body2"
sx={{
color: theme.palette.brightPurple.main,
fontWeight: "400",
fontSize: "16px",
mr: "4px",
height: "19px",
}}
onClick={() => addVariantWithFocus(question)}
>
Добавьте ответ
</Link>
{isMobile ? null : (
<>
<Typography
sx={{
fontWeight: 400,
lineHeight: "21.33px",
color: theme.palette.grey2.main,
fontSize: "16px",
}}
>
или нажмите Enter
</Typography>
<EnterIcon
style={{
color: "#7E2AEA",
fontSize: "24px",
marginLeft: "6px",
}}
/>
</>
)}
</Box>
{isMobile ? null : (
<>
<Typography
sx={{
fontWeight: 400,
lineHeight: "21.33px",
color: theme.palette.grey2.main,
fontSize: "16px",
}}
>
или нажмите Enter
</Typography>
<EnterIcon
style={{
color: "#7E2AEA",
fontSize: "24px",
marginLeft: "6px",
}}
/>
</>
<Typography
sx={{
fontWeight: 400,
lineHeight: "21.33px",
color: theme.palette.grey2.main,
fontSize: "16px",
mt: "4px",
}}
>
для переноса строки нажмите shift + enter
</Typography>
)}
</Box>
</Box>

@ -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<Props>(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 (
<Box
@ -84,7 +84,7 @@ const ResponseSettings = memo<Props>(function ({question, questionId, ownPlaceho
label={'Вариант "свой ответ"'}
checked={isOwn}
handleChange={({ target }) => {
switchOwn({question, checked:target.checked})
switchOwnVariant({question, checked:target.checked})
}}
/>
</Box>

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

@ -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({
}}
>
<Box
id="lol"
onScroll={onScroll}
ref={mainBlock}
sx={{
@ -140,6 +141,7 @@ export default function EditPage({
boxSizing: "border-box",
transition: "transform 0.3s",
paddingBottom: scrollDown && isMobile ? "120px" : undefined,
...pinkScrollbar(theme)
}}
>
{quizConfig && (

@ -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() {
</Box>
{backgroundUploding ? (
<Skeleton
variant="rounded"
sx={{
width: "48px",
height: "48px",
width: "300px",
height: "168px",
transform: "none",
margin: "20px 0",
my: "20px",
}}
/>
) : (
@ -465,16 +474,37 @@ export default function StartPageSettings() {
)}
</>
) : (
<Box sx={{ marginTop: "20px" }}>
<Box sx={{ position: "relative", marginTop: "20px", overflow: "hidden" }}>
<VideoElement
videoSrc={quiz.config.startpage.background.video}
theme={theme}
onLoaded={() => {
console.log('[StartPage] VideoElement reported loaded');
setBackgroundUploading(false);
}}
showSkeleton={backgroundUploding}
onDeleteClick={() => {
updateQuiz(quiz.id, (quiz) => {
quiz.config.startpage.background.video = null;
});
}}
/>
{backgroundUploding && (
<Skeleton
variant="rounded"
animation="wave"
sx={{
position: "absolute",
inset: 0,
width: "100%",
height: "100%",
borderRadius: "8px",
zIndex: (t) => t.zIndex.modal + 1,
bgcolor: (t) => t.palette.action.hover,
pointerEvents: "none",
}}
/>
)}
</Box>
)}
</>
@ -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;

@ -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<Theme>;
onLoaded?: () => void;
showSkeleton?: boolean;
};
export const VideoElement: FC<VideoElementProps> = ({
@ -18,10 +21,62 @@ export const VideoElement: FC<VideoElementProps> = ({
theme,
onDeleteClick,
deleteIconSx,
onLoaded,
showSkeleton = true,
}) => {
const containerRef = useRef<HTMLDivElement | null>(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 (
<Box sx={{ position: "relative", width: `${width}px` }}>
<Box ref={containerRef} sx={{ position: "relative", width: `${width}px`, minHeight: "168px" }}>
<QuizVideo videoUrl={videoSrc} />
{!isLoaded && showSkeleton && (
<Skeleton
sx={{ position: "absolute", inset: 0, width: "100%", height: "100%", borderRadius: "8px", zIndex: 1000, backgroundColor: 'rgba(255,255,255,0.8)' }}
variant="rounded"
/>
)}
<IconButton
onClick={onDeleteClick}
sx={{

@ -327,7 +327,8 @@ export const updateQuestion = async <T = AnyTypedQuizQuestion>(
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) => {

@ -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<string>(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
};
}

@ -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) =>

@ -17,12 +17,12 @@ interface CustomTextFieldProps {
value?: string;
error?: string;
emptyError?: boolean;
onChange?: (event: ChangeEvent<HTMLInputElement>) => void;
onKeyDown?: (event: KeyboardEvent<HTMLInputElement>) => void;
onBlur?: (event: FocusEvent<HTMLInputElement>) => void;
onFocus?: (event: FocusEvent<HTMLInputElement>) => void;
onClick?: (event: MouseEvent<HTMLInputElement>) => void;
onPaste?: (event: ClipboardEvent<HTMLInputElement>) => void;
onChange?: (event: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => void;
onKeyDown?: (event: KeyboardEvent<HTMLInputElement | HTMLTextAreaElement>) => void;
onBlur?: (event: FocusEvent<HTMLInputElement | HTMLTextAreaElement>) => void;
onFocus?: (event: FocusEvent<HTMLInputElement | HTMLTextAreaElement>) => void;
onClick?: (event: MouseEvent<HTMLInputElement | HTMLTextAreaElement>) => void;
onPaste?: (event: ClipboardEvent<HTMLInputElement | HTMLTextAreaElement>) => void;
text?: string;
maxLength?: number;
sx?: SxProps<Theme>;
@ -32,7 +32,7 @@ interface CustomTextFieldProps {
rows?: number;
className?: string;
disabled?: boolean;
inputRef?: Ref<HTMLInputElement>;
inputRef?: Ref<HTMLInputElement | HTMLTextAreaElement>;
}
export default function CustomTextField({
@ -67,7 +67,9 @@ export default function CustomTextField({
setInputValue(value);
}, [value]);
const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const handleInputChange = (
event: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
) => {
if (event.target.value.length <= maxLength) {
const inputValue = event.target.value;
@ -85,12 +87,16 @@ export default function CustomTextField({
}
};
const handleInputFocus = (event: React.FocusEvent<HTMLInputElement>) => {
const handleInputFocus = (
event: React.FocusEvent<HTMLInputElement | HTMLTextAreaElement>
) => {
setIsInputActive(true);
if (onFocus) onFocus(event);
};
const handleInputBlur = (event: React.FocusEvent<HTMLInputElement>) => {
const handleInputBlur = (
event: React.FocusEvent<HTMLInputElement | HTMLTextAreaElement>
) => {
setIsInputActive(false);
if (onBlur) {
@ -98,6 +104,12 @@ export default function CustomTextField({
}
};
const mergedInputElementProps = {
...(InputProps?.inputProps as any),
onClick,
onPaste,
};
return (
<FormControl
fullWidth
@ -126,14 +138,13 @@ export default function CustomTextField({
onFocus={handleInputFocus}
onBlur={handleInputBlur}
onKeyDown={onKeyDown}
onClick={onClick}
onPaste={onPaste}
multiline={rows > 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"

@ -68,14 +68,17 @@ export const MediaSelectionAndDisplay: FC<Iprops> = ({
}
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<Iprops> = ({
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 (

@ -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 <Outlet />;
}

@ -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`;

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

@ -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<QuizQuestionVariant>(question.id, (question) => {
question.content.own = checked;
});
}
return {
onClickAddAnAnswer,
switchOwn
};
};

@ -25,36 +25,47 @@ export function useAnalytics({ ready, quizId, to, from }: useAnalyticsProps) {
const [devices, setDevices] = useState<DevicesResponse | null>(null);
const [general, setGeneral] = useState<GeneralResponse | null>(null);
const [questions, setQuestions] = useState<QuestionsResponse | null>(null);
const [isLoading, setIsLoading] = useState<boolean>(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 };
}

@ -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) {

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

@ -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<string | null>(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<QuizQuestionVariant>(question.id, (question) => {
question.content.own = checked;
});
}
return {
addVariantWithFocus,
addVariantOnEnter,
switchOwnVariant,
focusedVariantId,
clearFocusedVariant
};
};