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 #!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.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 { General } from "./General";
import { AnswersStatistics } from "./Answers/AnswersStatistics"; import { AnswersStatistics } from "./Answers/AnswersStatistics";
import { Devices } from "./Devices"; import { Devices } from "./Devices";
import AnalyticsSkeleton from "./AnalyticsSkeleton";
import { setQuizes } from "@root/quizes/actions"; import { setQuizes } from "@root/quizes/actions";
import { useQuizStore } from "@root/quizes/store"; 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 { Moment } from "moment";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import type { Quiz } from "@model/quiz/quiz"; import type { Quiz } from "@model/quiz/quiz";
import { useCurrentQuiz } from "@/stores/quizes/hooks";
import { useQuestions } from "@/stores/questions/hooks";
export default function Analytics() { 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 [isOpen, setOpen] = useState<boolean>(false);
const [isOpenEnd, setOpenEnd] = useState<boolean>(false); const [isOpenEnd, setOpenEnd] = useState<boolean>(false);
const [from, setFrom] = useState<Moment | null>(null); const [from, setFrom] = useState<Moment | null>(null);
@ -45,30 +52,25 @@ export default function Analytics() {
const theme = useTheme(); const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down(600)); const isMobile = useMediaQuery(theme.breakpoints.down(600));
const { devices, general, questions } = useAnalytics({ const { devices, general, questions, isLoading } = useAnalytics({
ready: Boolean(Object.keys(quiz).length), ready: quiz ? Boolean(Object.keys(quiz).length) : false,
quizId: editQuizId?.toString() || "", quizId: quiz?.backendId?.toString() || "",
from, from,
to, to,
}); });
const resetTime = () => { const resetTime = () => {
setFrom(moment(new Date(quiz.created_at))); setFrom(moment(new Date(quiz?.created_at)));
setTo(moment().add(1, "days")); setTo(moment().add(1, "days"));
}; };
useEffect(() => { useEffect(() => {
if (quizes.length > 0) { if (quiz) setFrom(moment(new Date(quiz?.created_at)));
const quiz = quizes.find((q) => q.backendId === editQuizId); }, [quiz]);
if (quiz === undefined) throw new Error("Не удалось получить квиз");
setQuiz(quiz);
setFrom(moment(new Date(quiz.created_at)));
}
}, [quizes]);
useEffect(() => { useEffect(() => {
const getData = async (): Promise<void> => { const getData = async (): Promise<void> => {
if (editQuizId !== null) { if (quiz?.backendId !== null) {
const [gottenQuizes, gottenQuizesError] = await quizApi.getList(); const [gottenQuizes, gottenQuizesError] = await quizApi.getList();
if (gottenQuizesError) { if (gottenQuizesError) {
@ -85,8 +87,8 @@ export default function Analytics() {
}, []); }, []);
useLayoutEffect(() => { useLayoutEffect(() => {
if (editQuizId === undefined) redirect("/list"); if (quiz?.backendId === undefined) redirect("/list");
}, [editQuizId]); }, [quiz?.backendId]);
const handleClose = () => { const handleClose = () => {
setOpen(false); setOpen(false);
@ -255,12 +257,18 @@ export default function Analytics() {
{isMobile ? <ResetIcon /> : "Сбросить"} {isMobile ? <ResetIcon /> : "Сбросить"}
</Button> </Button>
</Box> </Box>
{isLoading ? (
<AnalyticsSkeleton />
) : (
<>
<General <General
data={general} data={general}
day={86400 - moment(to).unix() - moment(from).unix() > 0} day={86400 - moment(to).unix() - moment(from).unix() > 0}
/> />
<AnswersStatistics data={questions} /> <AnswersStatistics data={questions} globalQuestions={globalQuestions}/>
<Devices data={devices} /> <Devices data={devices} />
</>
)}
</SectionWrapper> </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 { import {
Box, Box,
ButtonBase, ButtonBase,
IconButton,
Input, Input,
LinearProgress, LinearProgress,
Pagination as MuiPagination, 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 { ReactComponent as RightArrowIcon } from "@icons/Analytics/rightArrow.svg";
import { extractOrder } from "@utils/extractOrder"; import { extractOrder } from "@utils/extractOrder";
import { parseTitle } from "../utils/parseTitle"; 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 = { type AnswerProps = {
title: string; title: string;
@ -28,6 +33,7 @@ type AnswerProps = {
type AnswersProps = { type AnswersProps = {
data: Record<string, Record<string, number>> | null; data: Record<string, Record<string, number>> | null;
globalQuestions: AnyTypedQuizQuestion[];
}; };
type PaginationProps = { type PaginationProps = {
@ -40,9 +46,13 @@ const Answer = ({ title, percent, highlight }: AnswerProps) => {
const theme = useTheme(); const theme = useTheme();
const parsedTitle = parseTitle(title); const parsedTitle = parseTitle(title);
console.log("parsedTitle: " + parsedTitle); console.log("Привет, я Answer. И вот что я о себе знаю:")
console.log("-------------------------------------------------------------------")
console.log("{ title, percent, highlight }")
console.log({ title, percent, highlight })
return ( return (
<Box sx={{ padding: "15px 25px" }}> <Box sx={{ padding: "15px 25px", width: "100%" }}>
<Box <Box
sx={{ sx={{
position: "relative", 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 [page, setPage] = useState<number>(1);
const theme = useTheme(); const theme = useTheme();
const answers = useMemo(() => { const answers = useMemo(() => {
@ -210,10 +221,25 @@ export const Answers: FC<AnswersProps> = ({ data }) => {
); );
}, [data]); }, [data]);
const currentAnswer = answers[page - 1]; 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( const percentsSum = Object.values(currentAnswer?.[1] ?? {}).reduce(
(total, item) => (total += item), (total, item) => (total += item),
0, 0,
); );
console.log("currentAnswer")
console.log(currentAnswer)
const currentAnswerExtended = const currentAnswerExtended =
percentsSum >= 100 percentsSum >= 100
? Object.entries(currentAnswer?.[1] ?? {}) ? Object.entries(currentAnswer?.[1] ?? {})
@ -287,16 +313,92 @@ export const Answers: FC<AnswersProps> = ({ data }) => {
<NextIcon /> <NextIcon />
</ButtonBase> */} </ButtonBase> */}
</Box> </Box>
{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 (
<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 < Answer
key={index} key={index}
title={title} title={title}
percent={percent} percent={percent}
highlight={!index} highlight={!index}
/> />
))} </Box>
)
} else {
return (
< Answer
key={index}
title={title}
percent={percent}
highlight={!index}
/>
)
}
})}
</Paper > </Paper >
<Pagination page={page} setPage={setPage} pagesAmount={answers.length} /> <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 { FC } from "react";
import { Funnel } from "./FunnelAnswers/Funnel"; import { Funnel } from "./FunnelAnswers/Funnel";
import { Results } from "./Results"; import { Results } from "./Results";
import { AnyTypedQuizQuestion } from "@frontend/squzanswerer";
type AnswersStatisticsProps = { type AnswersStatisticsProps = {
data: QuestionsResponse | null; data: QuestionsResponse | null;
globalQuestions: AnyTypedQuizQuestion[];
}; };
export const AnswersStatistics: FC<AnswersStatisticsProps> = ({ data }) => { export const AnswersStatistics: FC<AnswersStatisticsProps> = ({ data, globalQuestions }) => {
const theme = useTheme(); const theme = useTheme();
const isSmallMonitor = useMediaQuery(theme.breakpoints.down(1150)); const isSmallMonitor = useMediaQuery(theme.breakpoints.down(1150));
const isMobile = useMediaQuery(theme.breakpoints.down(850)); const isMobile = useMediaQuery(theme.breakpoints.down(850));
@ -33,7 +35,7 @@ export const AnswersStatistics: FC<AnswersStatisticsProps> = ({ data }) => {
gap: "40px", gap: "40px",
}} }}
> >
<Answers data={data?.Questions || null} /> <Answers data={data?.Questions || null} globalQuestions={globalQuestions} />
<Funnel data={data?.Funnel || []} funnelData={data?.FunnelData || []} /> <Funnel data={data?.Funnel || []} funnelData={data?.FunnelData || []} />
</Box> </Box>
<Results data={data?.Results || null} /> <Results data={data?.Results || null} />

@ -41,14 +41,20 @@ const GeneralItem = ({
const data = Object.entries(general).sort( const data = Object.entries(general).sort(
([nextValue], [currentValue]) => Number(nextValue) - Number(currentValue), ([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 let numberValue = 0
: conversionValue
numberValue = conversionValue
? conversionValue ? conversionValue
: Object.values(general).reduce((total, item) => total + item, 0); : 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 ( if (
Object.keys(general).length === 0 || Object.keys(general).length === 0 ||
Object.values(general).every((item) => item === 0) Object.values(general).every((item) => item === 0)
@ -150,7 +156,7 @@ export const General: FC<GeneralProps> = ({ data, day }) => {
(total, item) => total + item, (total, item) => total + item,
0, 0,
); );
const openSum = Object.values(generalResponse.Open).reduce( const openSum = Object.values(generalResponse.Open).filter(e => e).reduce(
(total, item) => total + item, (total, item) => total + item,
0, 0,
); );

@ -19,25 +19,19 @@ export const parseTitle = (title: string): string => {
try { try {
// Пытаемся распарсить как JSON // Пытаемся распарсить как JSON
const parsed = JSON.parse(cleanTitle); 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) // Проверяем, что это объект с полями Image и Description (специфичный для вопросов типа images и varimg)
if (parsed && if (parsed &&
typeof parsed === 'object' && typeof parsed === 'object' &&
'Image' in parsed && 'Image' in parsed &&
'Description' in parsed) { 'Description' in parsed) {
console.log("Returning Description:", parsed.Description);
return parsed.Description || "нет названия"; return parsed.Description || "нет названия";
} }
// Если это не объект с Image и Description, возвращаем исходную строку // Если это не объект с Image и Description, возвращаем исходную строку
console.log("Not Image/Description object, returning original title");
return title; return title;
} catch (error) { } catch (error) {
// Если парсинг не удался, возвращаем исходную строку // Если парсинг не удался, возвращаем исходную строку
console.log("JSON parse error, returning original title");
return title; return title;
} }
}; };

@ -10,6 +10,7 @@ import { updateQuiz } from "@root/quizes/actions";
import { useCurrentQuiz } from "@root/quizes/hooks"; import { useCurrentQuiz } from "@root/quizes/hooks";
import type { DesignItem } from "./DesignGroup"; import type { DesignItem } from "./DesignGroup";
import { DesignGroup } from "./DesignGroup"; import { DesignGroup } from "./DesignGroup";
import pinkScrollbar from "@utils/pinkScrollbar";
import Desgin1 from "@icons/designs/smallSize/design1.jpg"; import Desgin1 from "@icons/designs/smallSize/design1.jpg";
import Desgin2 from "@icons/designs/smallSize/design2.jpg"; import Desgin2 from "@icons/designs/smallSize/design2.jpg";
@ -133,15 +134,7 @@ export const DesignFilling = ({
padding: "20px", padding: "20px",
height: "100%", height: "100%",
overflow: "auto", overflow: "auto",
scrollbarWidth: "auto", ...pinkScrollbar(theme)
"&::-webkit-scrollbar": {
display: "block",
width: "8px",
},
"&::-webkit-scrollbar-thumb": {
backgroundColor: theme.palette.brightPurple.main,
borderRadius: "4px",
},
}} }}
> >
<Box sx={{ display: "flex", gap: "20px", flexWrap: "wrap" }}> <Box sx={{ display: "flex", gap: "20px", flexWrap: "wrap" }}>
@ -199,3 +192,4 @@ export const DesignFilling = ({
</Box> </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 { FC, useState, useEffect } from "react";
import { Dialog, IconButton, Typography, useMediaQuery, useTheme, Box } from "@mui/material"; import { Dialog, IconButton, Typography, useMediaQuery, useTheme, Box, Button } from "@mui/material";
import CloseIcon from "@mui/icons-material/Close"; import CloseIcon from "@mui/icons-material/Close";
import { Quiz } from "@/model/quiz/quiz"; 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 = { type PostbackModalProps = {
isModalOpen: boolean; isModalOpen: boolean;
@ -19,6 +25,65 @@ export const PostbackModal: FC<PostbackModalProps> = ({
const theme = useTheme(); const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down(600)); const isMobile = useMediaQuery(theme.breakpoints.down(600));
const isTablet = useMediaQuery(theme.breakpoints.down(1000)); 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 ( return (
<Dialog <Dialog
@ -28,12 +93,12 @@ export const PostbackModal: FC<PostbackModalProps> = ({
PaperProps={{ PaperProps={{
sx: { sx: {
maxWidth: isTablet ? "100%" : "919px", maxWidth: isTablet ? "100%" : "919px",
height: "658px", height: isMobile ? "303px" : "214px",
// height: "314px",
borderRadius: "12px", borderRadius: "12px",
}, },
}} }}
> >
<Box>
<Box <Box
sx={{ sx={{
width: "100%", width: "100%",
@ -52,7 +117,7 @@ export const PostbackModal: FC<PostbackModalProps> = ({
color: theme.palette.grey2.main, color: theme.palette.grey2.main,
}} }}
> >
Интеграция с {companyName ? companyName : "Postback"} Автоматизация с {companyName ? companyName : "Postback"}
</Typography> </Typography>
<IconButton onClick={handleCloseModal}> <IconButton onClick={handleCloseModal}>
<CloseIcon /> <CloseIcon />
@ -65,10 +130,66 @@ export const PostbackModal: FC<PostbackModalProps> = ({
overflow: "auto", overflow: "auto",
}} }}
> >
<Typography variant="body1"> {!isMobile && <InstructionYoutubeLink />}
Интеграция с Postback находится в разработке.
<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={{
width: "100%"
}}
>
<Typography
sx={{
fontWeight: 500,
color: "black",
mt: isMobile ? 0 : "11px",
mb: "14px",
}}
>
Домен
</Typography> </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> </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> </Box>
</Dialog> </Dialog>
); );

@ -1,7 +1,13 @@
import { FC } from "react"; import { FC, useState, useEffect } from "react";
import { Dialog, IconButton, Typography, useMediaQuery, useTheme, Box } from "@mui/material"; import { Dialog, IconButton, Typography, useMediaQuery, useTheme, Box, Button } from "@mui/material";
import CloseIcon from "@mui/icons-material/Close"; import CloseIcon from "@mui/icons-material/Close";
import { Quiz } from "@/model/quiz/quiz"; 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 = { type ZapierModalProps = {
isModalOpen: boolean; isModalOpen: boolean;
@ -19,6 +25,67 @@ export const ZapierModal: FC<ZapierModalProps> = ({
const theme = useTheme(); const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down(600)); const isMobile = useMediaQuery(theme.breakpoints.down(600));
const isTablet = useMediaQuery(theme.breakpoints.down(1000)); 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 ( return (
<Dialog <Dialog
@ -28,7 +95,7 @@ export const ZapierModal: FC<ZapierModalProps> = ({
PaperProps={{ PaperProps={{
sx: { sx: {
maxWidth: isTablet ? "100%" : "919px", maxWidth: isTablet ? "100%" : "919px",
height: "658px", height: isMobile ? "303px" : "195px",
borderRadius: "12px", borderRadius: "12px",
}, },
}} }}
@ -52,12 +119,13 @@ export const ZapierModal: FC<ZapierModalProps> = ({
color: theme.palette.grey2.main, color: theme.palette.grey2.main,
}} }}
> >
Интеграция с {companyName ? companyName : "Zapier"} Автоматизация с {companyName ? companyName : "Zapier"}
</Typography> </Typography>
<IconButton onClick={handleCloseModal}> <IconButton onClick={handleCloseModal}>
<CloseIcon /> <CloseIcon />
</IconButton> </IconButton>
</Box> </Box>
<Box <Box
sx={{ sx={{
padding: "20px", padding: "20px",
@ -65,9 +133,74 @@ export const ZapierModal: FC<ZapierModalProps> = ({
overflow: "auto", overflow: "auto",
}} }}
> >
<Typography variant="body1"> {!isMobile && <InstructionYoutubeLink />}
Интеграция с Zapier находится в разработке.
<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> </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>
</Box> </Box>
</Dialog> </Dialog>

@ -5,6 +5,7 @@ import { useCurrentQuiz } from "@root/quizes/hooks";
import { useQuizStore } from "@root/quizes/store"; import { useQuizStore } from "@root/quizes/store";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { PartnersBoard } from "./PartnersBoard/PartnersBoard"; import { PartnersBoard } from "./PartnersBoard/PartnersBoard";
import { getLeadTargetsByQuiz, LeadTargetModel } from "@/api/leadtarget";
import { QuizMetricType } from "@model/quizSettings"; import { QuizMetricType } from "@model/quizSettings";
interface IntegrationsPageProps { interface IntegrationsPageProps {
@ -29,10 +30,42 @@ export const IntegrationsPage = ({
const [isAmoCrmModalOpen, setIsAmoCrmModalOpen] = useState<boolean>(false); const [isAmoCrmModalOpen, setIsAmoCrmModalOpen] = useState<boolean>(false);
const [isZapierModalOpen, setIsZapierModalOpen] = useState<boolean>(false); const [isZapierModalOpen, setIsZapierModalOpen] = useState<boolean>(false);
const [isPostbackModalOpen, setIsPostbackModalOpen] = 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(() => { useEffect(() => {
if (editQuizId === null) navigate("/list"); if (editQuizId === null) navigate("/list");
}, [navigate, editQuizId]); }, [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; const heightBar = heightSidebar + 51 + 88 + 36 + 25;
if (quiz === undefined) if (quiz === undefined)
@ -87,6 +120,8 @@ export const IntegrationsPage = ({
setIsPostbackModalOpen={setIsPostbackModalOpen} setIsPostbackModalOpen={setIsPostbackModalOpen}
isPostbackModalOpen={isPostbackModalOpen} isPostbackModalOpen={isPostbackModalOpen}
handleClosePostbackModal={handleClosePostbackModal} handleClosePostbackModal={handleClosePostbackModal}
zapierTarget={zapierTarget}
postbackTarget={postbackTarget}
/> />
</Box> </Box>
</> </>

@ -7,6 +7,7 @@ import { YandexMetricaLogo } from "../mocks/YandexMetricaLogo";
import { VKPixelLogo } from "../mocks/VKPixelLogo"; import { VKPixelLogo } from "../mocks/VKPixelLogo";
import { QuizMetricType } from "@model/quizSettings"; import { QuizMetricType } from "@model/quizSettings";
import { AmoCRMLogo } from "../mocks/AmoCRMLogo"; import { AmoCRMLogo } from "../mocks/AmoCRMLogo";
import type { LeadTargetModel } from "@/api/leadtarget";
import { useCurrentQuiz } from "@/stores/quizes/hooks"; import { useCurrentQuiz } from "@/stores/quizes/hooks";
const AnalyticsModal = lazy(() => const AnalyticsModal = lazy(() =>
@ -48,6 +49,8 @@ type PartnersBoardProps = {
setIsPostbackModalOpen: (value: boolean) => void; setIsPostbackModalOpen: (value: boolean) => void;
isPostbackModalOpen: boolean; isPostbackModalOpen: boolean;
handleClosePostbackModal: () => void; handleClosePostbackModal: () => void;
zapierTarget?: LeadTargetModel | null;
postbackTarget?: LeadTargetModel | null;
}; };
export const PartnersBoard: FC<PartnersBoardProps> = ({ export const PartnersBoard: FC<PartnersBoardProps> = ({
@ -65,6 +68,8 @@ export const PartnersBoard: FC<PartnersBoardProps> = ({
setIsPostbackModalOpen, setIsPostbackModalOpen,
isPostbackModalOpen, isPostbackModalOpen,
handleClosePostbackModal, handleClosePostbackModal,
zapierTarget,
postbackTarget,
}) => { }) => {
const theme = useTheme(); const theme = useTheme();
const quiz = useCurrentQuiz(); const quiz = useCurrentQuiz();
@ -132,7 +137,7 @@ export const PartnersBoard: FC<PartnersBoardProps> = ({
/> />
</Box> </Box>
{/* <Typography variant="h6" sx={sectionTitleStyles}> <Typography variant="h6" sx={sectionTitleStyles}>
Автоматизация Автоматизация
</Typography> </Typography>
<Box sx={containerStyles}> <Box sx={containerStyles}>
@ -144,7 +149,7 @@ export const PartnersBoard: FC<PartnersBoardProps> = ({
setIsModalOpen={setIsPostbackModalOpen} setIsModalOpen={setIsPostbackModalOpen}
setCompanyName={setCompanyName} setCompanyName={setCompanyName}
/> />
</Box> */} </Box>
</Box> </Box>
{companyName && ( {companyName && (
@ -173,6 +178,7 @@ export const PartnersBoard: FC<PartnersBoardProps> = ({
handleCloseModal={handleCloseZapierModal} handleCloseModal={handleCloseZapierModal}
companyName={companyName} companyName={companyName}
quiz={quiz!} quiz={quiz!}
currentTarget={zapierTarget ?? null}
/> />
</Suspense> </Suspense>
)} )}
@ -183,6 +189,7 @@ export const PartnersBoard: FC<PartnersBoardProps> = ({
handleCloseModal={handleClosePostbackModal} handleCloseModal={handleClosePostbackModal}
companyName={companyName} companyName={companyName}
quiz={quiz!} quiz={quiz!}
currentTarget={postbackTarget ?? null}
/> />
</Suspense> </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 Box from "@mui/material/Box";
import Button from "@mui/material/Button"; import Button from "@mui/material/Button";
import SectionStyled from "./SectionStyled"; import SectionStyled from "./SectionStyled";
import NavMenuItem from "@ui_kit/Header/NavMenuItem";
import QuizLogo from "./images/icons/QuizLogo"; import QuizLogo from "./images/icons/QuizLogo";
import { useMediaQuery, useTheme } from "@mui/material"; import { useMediaQuery, useTheme } from "@mui/material";
import { setIsContactFormOpen } from "../../stores/contactForm"; import { setIsContactFormOpen } from "../../stores/contactForm";
@ -26,12 +25,6 @@ export default function Component() {
const userId = useUserStore((state) => state.userId); const userId = useUserStore((state) => state.userId);
const location = useLocation(); const location = useLocation();
console.log("HeaderLanding debug:", {
userId,
location: location.pathname,
backgroundLocation: location.state?.backgroundLocation
});
return ( return (
<SectionStyled <SectionStyled
tag={"header"} tag={"header"}

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

@ -16,6 +16,9 @@ type Props = Omit<
openImageUploadModal: () => void; openImageUploadModal: () => void;
isOwn: boolean; isOwn: boolean;
ownPlaceholder: string; ownPlaceholder: string;
shouldAutoFocus?: boolean;
onFocusHandled?: () => void;
onEnterKeyPress?: () => void;
}; };
export default function ImageEditAnswerItem({ export default function ImageEditAnswerItem({
@ -31,6 +34,9 @@ export default function ImageEditAnswerItem({
openImageUploadModal, openImageUploadModal,
isOwn, isOwn,
ownPlaceholder, ownPlaceholder,
shouldAutoFocus,
onFocusHandled,
onEnterKeyPress,
}: Props) { }: Props) {
const addOrEditImageButton = useMemo(() => { const addOrEditImageButton = useMemo(() => {
return ( return (
@ -111,6 +117,9 @@ export default function ImageEditAnswerItem({
additionalMobile={addOrEditImageButtonMobile} additionalMobile={addOrEditImageButtonMobile}
isOwn={isOwn} isOwn={isOwn}
ownPlaceholder={ownPlaceholder} ownPlaceholder={ownPlaceholder}
shouldAutoFocus={shouldAutoFocus}
onFocusHandled={onFocusHandled}
onEnterKeyPress={onEnterKeyPress}
/> />
); );
} }

@ -1,7 +1,7 @@
import { Box, Link, Typography, useMediaQuery, useTheme } from "@mui/material"; import { Box, Link, Typography, useMediaQuery, useTheme } from "@mui/material";
import { useCallback, useState } from "react"; import { useCallback, useState } from "react";
import { EnterIcon } from "@/assets/icons/questionsPage/enterIcon"; import { EnterIcon } from "@/assets/icons/questionsPage/enterIcon";
import { useAddAnswer } from "../../../utils/hooks/useAddAnswer"; import { useQuestionVariantsWithFocus } from "../../../utils/questionVariants";
import { AnswerDraggableList } from "../AnswerDraggableList"; import { AnswerDraggableList } from "../AnswerDraggableList";
import ButtonsOptions from "../QuestionOptions/ButtonsLayout/ButtonsOptions"; import ButtonsOptions from "../QuestionOptions/ButtonsLayout/ButtonsOptions";
import SwitchDropDown from "./switchDropDown"; import SwitchDropDown from "./switchDropDown";
@ -16,7 +16,7 @@ interface Props {
} }
export default function DropDown({ question, openBranchingPage, setOpenBranchingPage }: Props) { export default function DropDown({ question, openBranchingPage, setOpenBranchingPage }: Props) {
const {onClickAddAnAnswer} = useAddAnswer(); const {addVariantWithFocus, addVariantOnEnter, focusedVariantId, clearFocusedVariant} = useQuestionVariantsWithFocus();
const [switchState, setSwitchState] = useState("setting"); const [switchState, setSwitchState] = useState("setting");
const theme = useTheme(); const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down(790)); const isMobile = useMediaQuery(theme.breakpoints.down(790));
@ -50,17 +50,17 @@ export default function DropDown({ question, openBranchingPage, setOpenBranching
disableKeyDown={question.content.variants.length >= 100} disableKeyDown={question.content.variants.length >= 100}
questionId={question.id} questionId={question.id}
variant={variant} variant={variant}
isOwn={Boolean(variant?.isOwn)}
ownPlaceholder={""}
shouldAutoFocus={focusedVariantId === variant.id}
onFocusHandled={clearFocusedVariant}
onEnterKeyPress={() => addVariantOnEnter(question.id)}
/> />
))} ))}
/> />
)} )}
<Box <Box sx={{ display: "flex", flexDirection: "column", alignItems: "flex-start", marginBottom: "20px" }}>
sx={{ <Box sx={{ display: "flex", alignItems: "center" }}>
display: "flex",
alignItems: "center",
marginBottom: "20px",
}}
>
<Link <Link
component="button" component="button"
variant="body2" variant="body2"
@ -71,7 +71,7 @@ export default function DropDown({ question, openBranchingPage, setOpenBranching
mr: "4px", mr: "4px",
height: "19px", height: "19px",
}} }}
onClick={() => onClickAddAnAnswer(question)} onClick={() => addVariantWithFocus(question)}
> >
Добавьте ответ Добавьте ответ
</Link> </Link>
@ -97,6 +97,20 @@ export default function DropDown({ question, openBranchingPage, setOpenBranching
</> </>
)} )}
</Box> </Box>
{isMobile ? null : (
<Typography
sx={{
fontWeight: 400,
lineHeight: "21.33px",
color: theme.palette.grey2.main,
fontSize: "16px",
mt: "4px",
}}
>
нажмите shift + enter для переноса строки
</Typography>
)}
</Box>
</Box> </Box>
<ButtonsOptions <ButtonsOptions
switchState={switchState} switchState={switchState}
@ -105,6 +119,7 @@ export default function DropDown({ question, openBranchingPage, setOpenBranching
questionContentId={question.content.id} questionContentId={question.content.id}
questionType={question.type} questionType={question.type}
questionHasParent={question.content.rule.parentId?.length !== 0} questionHasParent={question.content.rule.parentId?.length !== 0}
openBranchingPage={openBranchingPage}
setOpenBranchingPage={setOpenBranchingPage} setOpenBranchingPage={setOpenBranchingPage}
/> />
<SwitchDropDown <SwitchDropDown

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

@ -14,6 +14,9 @@ type Props = Omit<
setOpen: React.Dispatch<React.SetStateAction<boolean>>; setOpen: React.Dispatch<React.SetStateAction<boolean>>;
isOwn: boolean; isOwn: boolean;
ownPlaceholder: string; ownPlaceholder: string;
shouldAutoFocus?: boolean;
onFocusHandled?: () => void;
onEnterKeyPress?: () => void;
}; };
export default function EmojiAnswerItem({ export default function EmojiAnswerItem({
@ -28,6 +31,9 @@ export default function EmojiAnswerItem({
setOpen, setOpen,
isOwn, isOwn,
ownPlaceholder, ownPlaceholder,
shouldAutoFocus,
onFocusHandled,
onEnterKeyPress,
}: Props) { }: Props) {
@ -99,6 +105,9 @@ export default function EmojiAnswerItem({
additionalMobile={addOrEditImageButtonMobile} additionalMobile={addOrEditImageButtonMobile}
isOwn={isOwn} isOwn={isOwn}
ownPlaceholder={ownPlaceholder} 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 CustomCheckbox from "@ui_kit/CustomCheckbox";
import { memo } from "react"; import { memo } from "react";
import CustomTextField from "@ui_kit/CustomTextField"; import CustomTextField from "@ui_kit/CustomTextField";
import { useAddAnswer } from "@/utils/hooks/useAddAnswer"; import { useQuestionVariantsWithFocus } from "@/utils/questionVariants";
type SettingEmojiProps = { type SettingEmojiProps = {
question: QuizQuestionEmoji; question: QuizQuestionEmoji;
@ -17,7 +17,7 @@ type SettingEmojiProps = {
const SettingEmoji = memo<SettingEmojiProps>(function ({ question, questionId, isRequired, isLargeCheck, isMulti, isOwn }) { const SettingEmoji = memo<SettingEmojiProps>(function ({ question, questionId, isRequired, isLargeCheck, isMulti, isOwn }) {
const theme = useTheme(); const theme = useTheme();
const {switchOwn} = useAddAnswer(); const {switchOwnVariant} = useQuestionVariantsWithFocus();
const isWrappColumn = useMediaQuery(theme.breakpoints.down(980)); const isWrappColumn = useMediaQuery(theme.breakpoints.down(980));
const isFigmaTablte = useMediaQuery(theme.breakpoints.down(990)); const isFigmaTablte = useMediaQuery(theme.breakpoints.down(990));
@ -92,7 +92,7 @@ const SettingEmoji = memo<SettingEmojiProps>(function ({ question, questionId, i
label={'Вариант "свой ответ"'} label={'Вариант "свой ответ"'}
checked={isOwn} checked={isOwn}
handleChange={({ target }) => { handleChange={({ target }) => {
switchOwn({question, checked:target.checked}) switchOwnVariant({question, checked:target.checked})
}} }}
/> />
{/* <Box sx={{ mt: isMobile ? "11px" : "6px", width: "100%" }}> {/* <Box sx={{ mt: isMobile ? "11px" : "6px", width: "100%" }}>

@ -1,9 +1,9 @@
import { Box, Link, Typography, useMediaQuery, useTheme } from "@mui/material"; import { Box, Link, Typography, useMediaQuery, useTheme } from "@mui/material";
import { import {
addQuestionVariant,
clearQuestionImages, clearQuestionImages,
uploadQuestionImage, uploadQuestionImage,
} from "@root/questions/actions"; } from "@root/questions/actions";
import { useQuestionVariantsWithFocus } from "@/utils/questionVariants";
import { useCurrentQuiz } from "@root/quizes/hooks"; import { useCurrentQuiz } from "@root/quizes/hooks";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { EnterIcon } from "@/assets/icons/questionsPage/enterIcon"; import { EnterIcon } from "@/assets/icons/questionsPage/enterIcon";
@ -31,6 +31,7 @@ export default function OptionsAndPicture({
const [switchState, setSwitchState] = useState("setting"); const [switchState, setSwitchState] = useState("setting");
const [pictureUploding, setPictureUploading] = useState<boolean>(false); const [pictureUploding, setPictureUploading] = useState<boolean>(false);
const [openCropModal, setOpenCropModal] = useState(false); const [openCropModal, setOpenCropModal] = useState(false);
const {addVariantWithFocus, addVariantOnEnter, focusedVariantId, clearFocusedVariant} = useQuestionVariantsWithFocus();
const [selectedVariantId, setSelectedVariantId] = useState<string | null>( const [selectedVariantId, setSelectedVariantId] = useState<string | null>(
null, null,
@ -111,6 +112,9 @@ export default function OptionsAndPicture({
setSelectedVariantId={setSelectedVariantId} setSelectedVariantId={setSelectedVariantId}
isOwn={Boolean(variant?.isOwn)} isOwn={Boolean(variant?.isOwn)}
ownPlaceholder={question.content.ownPlaceholder} ownPlaceholder={question.content.ownPlaceholder}
shouldAutoFocus={focusedVariantId === variant.id}
onFocusHandled={clearFocusedVariant}
onEnterKeyPress={() => addVariantOnEnter(question.id)}
/> />
))} ))}
/> />
@ -130,13 +134,8 @@ export default function OptionsAndPicture({
selfClose={() => setOpenCropModal(false)} selfClose={() => setOpenCropModal(false)}
setPictureUploading={setPictureUploading} setPictureUploading={setPictureUploading}
/> />
<Box <Box sx={{ display: "flex", flexDirection: "column", alignItems: "flex-start", marginBottom: "17px" }}>
sx={{ <Box sx={{ display: "flex", alignItems: "center" }}>
display: "flex",
alignItems: "center",
marginBottom: "17px",
}}
>
<Link <Link
component="button" component="button"
variant="body2" variant="body2"
@ -148,7 +147,7 @@ export default function OptionsAndPicture({
height: "19px", height: "19px",
}} }}
onClick={() => { onClick={() => {
addQuestionVariant(question.id); addVariantWithFocus(question);
}} }}
> >
Добавьте ответ Добавьте ответ
@ -175,6 +174,20 @@ export default function OptionsAndPicture({
</> </>
)} )}
</Box> </Box>
{isMobile ? null : (
<Typography
sx={{
fontWeight: 400,
lineHeight: "21.33px",
color: theme.palette.grey2.main,
fontSize: "16px",
mt: "4px",
}}
>
для переноса строки нажмите shift + enter
</Typography>
)}
</Box>
</Box> </Box>
<ButtonsOptions <ButtonsOptions
switchState={switchState} switchState={switchState}

@ -1,4 +1,4 @@
import { useAddAnswer } from "@/utils/hooks/useAddAnswer"; import { useQuestionVariantsWithFocus } from "@/utils/questionVariants";
import type { QuizQuestionVarImg, QuizQuestionVariant } from "@frontend/squzanswerer"; import type { QuizQuestionVarImg, QuizQuestionVariant } from "@frontend/squzanswerer";
import { Box, Typography, useMediaQuery, useTheme } from "@mui/material"; import { Box, Typography, useMediaQuery, useTheme } from "@mui/material";
import { updateQuestion } from "@root/questions/actions"; 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 SettingOptionsAndPict = memo<SettingOptionsAndPictProps>(function ({ question, questionId, ownPlaceholder, isMulti, isLargeCheck, replText, isRequired, isOwn }) {
const theme = useTheme(); const theme = useTheme();
const { switchOwn } = useAddAnswer(); const { switchOwnVariant } = useQuestionVariantsWithFocus();
const isWrappColumn = useMediaQuery(theme.breakpoints.down(980)); const isWrappColumn = useMediaQuery(theme.breakpoints.down(980));
const isFigmaTablte = useMediaQuery(theme.breakpoints.down(990)); const isFigmaTablte = useMediaQuery(theme.breakpoints.down(990));
@ -73,7 +73,7 @@ const SettingOptionsAndPict = memo<SettingOptionsAndPictProps>(function ({ quest
label={'Вариант "свой ответ"'} label={'Вариант "свой ответ"'}
checked={isOwn} checked={isOwn}
handleChange={({ target }) => { handleChange={({ target }) => {
switchOwn({ question, checked: target.checked }) switchOwnVariant({ question, checked: target.checked })
}} }}
/> />
<CustomCheckbox <CustomCheckbox

@ -9,7 +9,7 @@ import { EnterIcon } from "@/assets/icons/questionsPage/enterIcon";
import type { QuizQuestionVarImg } from "@frontend/squzanswerer/dist-package/model/questionTypes/varimg"; import type { QuizQuestionVarImg } from "@frontend/squzanswerer/dist-package/model/questionTypes/varimg";
//@/model/questionTypes/images"; //@/model/questionTypes/images";
import { useAddAnswer } from "@/utils/hooks/useAddAnswer"; import { useQuestionVariantsWithFocus } from "@/utils/questionVariants";
import { useDisclosure } from "@/utils/useDisclosure"; import { useDisclosure } from "@/utils/useDisclosure";
import { AnswerDraggableList } from "../../AnswerDraggableList"; import { AnswerDraggableList } from "../../AnswerDraggableList";
import ImageEditAnswerItem from "../../AnswerDraggableList/ImageEditAnswerItem"; import ImageEditAnswerItem from "../../AnswerDraggableList/ImageEditAnswerItem";
@ -31,7 +31,7 @@ export default function OptionsPicture({
setOpenBranchingPage, setOpenBranchingPage,
}: Props) { }: Props) {
const theme = useTheme(); const theme = useTheme();
const {onClickAddAnAnswer} = useAddAnswer(); const {addVariantWithFocus, addVariantOnEnter, focusedVariantId, clearFocusedVariant} = useQuestionVariantsWithFocus();
const quizQid = useCurrentQuiz()?.qid; const quizQid = useCurrentQuiz()?.qid;
const [pictureUploding, setPictureUploading] = useState<boolean>(false); const [pictureUploding, setPictureUploading] = useState<boolean>(false);
const [openCropModal, setOpenCropModal] = useState(false); const [openCropModal, setOpenCropModal] = useState(false);
@ -87,12 +87,15 @@ export default function OptionsPicture({
largeCheck={question.content.largeCheck} largeCheck={question.content.largeCheck}
variant={variant} variant={variant}
isMobile={isMobile} isMobile={isMobile}
openCropModal={() => {setOpenCropModal(true)}} openCropModal={() => { setOpenCropModal(true); return Promise.resolve(); }}
openImageUploadModal={openImageUploadModal} openImageUploadModal={openImageUploadModal}
pictureUploding={pictureUploding} pictureUploding={pictureUploding}
setSelectedVariantId={setSelectedVariantId} setSelectedVariantId={setSelectedVariantId}
isOwn={Boolean(variant?.isOwn)} isOwn={Boolean(variant?.isOwn)}
ownPlaceholder={question.content.ownPlaceholder} ownPlaceholder={question.content.ownPlaceholder || ""}
shouldAutoFocus={focusedVariantId === variant.id}
onFocusHandled={clearFocusedVariant}
onEnterKeyPress={() => addVariantOnEnter(question.id)}
/> />
))} ))}
/> />
@ -102,22 +105,23 @@ export default function OptionsPicture({
handleImageChange={handleImageUpload} handleImageChange={handleImageUpload}
/> />
<CropModalInit <CropModalInit
originalImageUrl={variant?.originalImageUrl} originalImageUrl={variant?.originalImageUrl ?? ""}
editedUrlImagesList={variant?.editedUrlImagesList} editedUrlImagesList={(variant?.editedUrlImagesList as any) ?? undefined}
questionId={question.id.toString()} questionId={question.id.toString()}
questionType={question.type} questionType={question.type as any}
quizId={quizQid} quizId={quizQid ?? ""}
variantId={variant?.id} variantId={variant?.id ?? ""}
open={openCropModal} open={openCropModal}
selfClose={() => setOpenCropModal(false)} selfClose={() => setOpenCropModal(false)}
setPictureUploading={setPictureUploading} setPictureUploading={setPictureUploading as any}
/> />
<Box sx={{ display: "flex", alignItems: "center", gap: "10px" }}> <Box sx={{ display: "flex", flexDirection: "column", alignItems: "flex-start" }}>
<Box sx={{ display: "flex", alignItems: "center" }}>
<Link <Link
component="button" component="button"
variant="body2" variant="body2"
sx={{ color: theme.palette.brightPurple.main }} sx={{ color: theme.palette.brightPurple.main }}
onClick={() => onClickAddAnAnswer(question)} onClick={() => addVariantWithFocus(question)}
> >
Добавьте ответ Добавьте ответ
</Link> </Link>
@ -143,6 +147,20 @@ export default function OptionsPicture({
</> </>
)} )}
</Box> </Box>
{isMobile ? null : (
<Typography
sx={{
fontWeight: 400,
lineHeight: "21.33px",
color: theme.palette.grey2.main,
fontSize: "16px",
mt: "4px",
}}
>
для переноса строки нажмите shift + enter
</Typography>
)}
</Box>
</Box> </Box>
<ButtonsOptions <ButtonsOptions
switchState={switchState} switchState={switchState}
@ -151,11 +169,12 @@ export default function OptionsPicture({
questionContentId={question.content.id} questionContentId={question.content.id}
questionType={question.type} questionType={question.type}
questionHasParent={question.content.rule.parentId?.length !== 0} questionHasParent={question.content.rule.parentId?.length !== 0}
openBranchingPage={openBranchingPage}
setOpenBranchingPage={setOpenBranchingPage} setOpenBranchingPage={setOpenBranchingPage}
/> />
<SwitchAnswerOptionsPict <SwitchAnswerOptionsPict
switchState={switchState} 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 ProportionsIcon12 from "@/assets/icons/questionsPage/ProportionsIcon12";
import ProportionsIcon21 from "@/assets/icons/questionsPage/ProportionsIcon21"; import ProportionsIcon21 from "@/assets/icons/questionsPage/ProportionsIcon21";
import CustomTextField from "@ui_kit/CustomTextField"; 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"; type Proportion = "1:1" | "1:2" | "2:1";
@ -69,7 +69,7 @@ const SettingOptionsPict = memo<SettingOptionsPictProps>(function ({
question.content.ownPlaceholder = replText; question.content.ownPlaceholder = replText;
}); });
}; };
const {switchOwn} = useAddAnswer(); const {switchOwnVariant} = useQuestionVariantsWithFocus();
return ( return (
<Box <Box
@ -175,7 +175,7 @@ const SettingOptionsPict = memo<SettingOptionsPictProps>(function ({
label={'Вариант "свой ответ"'} label={'Вариант "свой ответ"'}
checked={isOwn} checked={isOwn}
handleChange={({ target }) => { handleChange={({ target }) => {
switchOwn({question, checked:target.checked}) switchOwnVariant({question, checked:target.checked})
}} }}
/> />
{/* <Box sx={{ mt: isMobile ? "11px" : "6px", width: "100%" }}> {/* <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 ProportionsIcon12 from "@/assets/icons/questionsPage/ProportionsIcon12";
import ProportionsIcon21 from "@/assets/icons/questionsPage/ProportionsIcon21"; import ProportionsIcon21 from "@/assets/icons/questionsPage/ProportionsIcon21";
import CustomTextField from "@ui_kit/CustomTextField"; 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"; type Proportion = "1:1" | "1:2" | "2:1";
@ -69,7 +69,7 @@ const SettingOptionsPict = memo<SettingOptionsPictProps>(function ({
question.content.ownPlaceholder = replText; question.content.ownPlaceholder = replText;
}); });
}; };
const {switchOwn} = useAddAnswer(); const {switchOwnVariant} = useQuestionVariantsWithFocus();
return ( return (
<Box <Box
@ -175,7 +175,7 @@ const SettingOptionsPict = memo<SettingOptionsPictProps>(function ({
label={'Вариант "свой ответ"'} label={'Вариант "свой ответ"'}
checked={isOwn} checked={isOwn}
handleChange={({ target }) => { handleChange={({ target }) => {
switchOwn({question, checked:target.checked}) switchOwnVariant({question, checked:target.checked})
}} }}
/> />
{/* <Box sx={{ mt: isMobile ? "11px" : "6px", width: "100%" }}> {/* <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 { useEffect, useState } from "react";
import { EnterIcon } from "@/assets/icons/questionsPage/enterIcon"; import { EnterIcon } from "@/assets/icons/questionsPage/enterIcon";
import type { QuizQuestionVariant } from "@frontend/squzanswerer"; import type { QuizQuestionVariant } from "@frontend/squzanswerer";
import { useAddAnswer } from "@/utils/hooks/useAddAnswer"; import { useQuestionVariantsWithFocus } from "@/utils/questionVariants";
import { AnswerDraggableList } from "../../AnswerDraggableList"; import { AnswerDraggableList } from "../../AnswerDraggableList";
import AnswerItem from "../../AnswerDraggableList/AnswerItem"; import AnswerItem from "../../AnswerDraggableList/AnswerItem";
import ButtonsOptions from "../ButtonsLayout/ButtonsOptions"; import ButtonsOptions from "../ButtonsLayout/ButtonsOptions";
@ -15,7 +15,7 @@ interface Props {
} }
export default function AnswerOptions({ question, openBranchingPage, setOpenBranchingPage }: Props) { export default function AnswerOptions({ question, openBranchingPage, setOpenBranchingPage }: Props) {
const {onClickAddAnAnswer} = useAddAnswer(); const {addVariantWithFocus, addVariantOnEnter, focusedVariantId, clearFocusedVariant} = useQuestionVariantsWithFocus();
const [switchState, setSwitchState] = useState("setting"); const [switchState, setSwitchState] = useState("setting");
const theme = useTheme(); const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down(790)); const isMobile = useMediaQuery(theme.breakpoints.down(790));
@ -54,19 +54,17 @@ export default function AnswerOptions({ question, openBranchingPage, setOpenBran
questionId={question.id} questionId={question.id}
variant={variant} variant={variant}
isOwn={Boolean(variant.isOwn)} isOwn={Boolean(variant.isOwn)}
ownPlaceholder={question.content.ownPlaceholder} ownPlaceholder={question.content.ownPlaceholder || ""}
shouldAutoFocus={focusedVariantId === variant.id}
onFocusHandled={clearFocusedVariant}
onEnterKeyPress={() => addVariantOnEnter(question.id)}
/> />
))} ))}
/> />
)} )}
<Box <Box sx={{ display: "flex", flexDirection: "column", alignItems: "flex-start", marginBottom: "17px" }}>
sx={{ <Box sx={{ display: "flex", alignItems: "center" }}>
display: "flex",
alignItems: "center",
marginBottom: "17px",
}}
>
<Link <Link
component="button" component="button"
variant="body2" variant="body2"
@ -77,7 +75,7 @@ export default function AnswerOptions({ question, openBranchingPage, setOpenBran
mr: "4px", mr: "4px",
height: "19px", height: "19px",
}} }}
onClick={() => onClickAddAnAnswer(question)} onClick={() => addVariantWithFocus(question)}
> >
Добавьте ответ Добавьте ответ
</Link> </Link>
@ -103,6 +101,20 @@ export default function AnswerOptions({ question, openBranchingPage, setOpenBran
</> </>
)} )}
</Box> </Box>
{isMobile ? null : (
<Typography
sx={{
fontWeight: 400,
lineHeight: "21.33px",
color: theme.palette.grey2.main,
fontSize: "16px",
mt: "4px",
}}
>
для переноса строки нажмите shift + enter
</Typography>
)}
</Box>
</Box> </Box>
<ButtonsOptions <ButtonsOptions
switchState={switchState} switchState={switchState}

@ -4,7 +4,7 @@ import CustomCheckbox from "@ui_kit/CustomCheckbox";
import type { QuizQuestionVariant } from "@frontend/squzanswerer"; import type { QuizQuestionVariant } from "@frontend/squzanswerer";
import { memo } from "react"; import { memo } from "react";
import CustomTextField from "@ui_kit/CustomTextField"; import CustomTextField from "@ui_kit/CustomTextField";
import { useAddAnswer } from "@/utils/hooks/useAddAnswer"; import { useQuestionVariantsWithFocus } from "@/utils/questionVariants";
interface Props { interface Props {
question: QuizQuestionVariant; question: QuizQuestionVariant;
@ -21,7 +21,7 @@ const ResponseSettings = memo<Props>(function ({question, questionId, ownPlaceho
const isTablet = useMediaQuery(theme.breakpoints.down(900)); const isTablet = useMediaQuery(theme.breakpoints.down(900));
const isFigmaTablte = useMediaQuery(theme.breakpoints.down(990)); const isFigmaTablte = useMediaQuery(theme.breakpoints.down(990));
const isMobile = useMediaQuery(theme.breakpoints.down(790)); const isMobile = useMediaQuery(theme.breakpoints.down(790));
const {switchOwn} = useAddAnswer(); const {switchOwnVariant} = useQuestionVariantsWithFocus();
return ( return (
<Box <Box
@ -84,7 +84,7 @@ const ResponseSettings = memo<Props>(function ({question, questionId, ownPlaceho
label={'Вариант "свой ответ"'} label={'Вариант "свой ответ"'}
checked={isOwn} checked={isOwn}
handleChange={({ target }) => { handleChange={({ target }) => {
switchOwn({question, checked:target.checked}) switchOwnVariant({question, checked:target.checked})
}} }}
/> />
</Box> </Box>

@ -55,7 +55,6 @@ export default function RecoverPassword() {
initialValues, initialValues,
validationSchema, validationSchema,
onSubmit: async (values, formikHelpers) => { onSubmit: async (values, formikHelpers) => {
console.log("tokenUser", tokenUser);
if (tokenUser) { if (tokenUser) {
setAuthToken(tokenUser || ""); setAuthToken(tokenUser || "");
@ -77,11 +76,8 @@ export default function RecoverPassword() {
}, },
}); });
useEffect(() => { 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 params = new URLSearchParams(window.location.search);
const authToken = params.get("auth"); const authToken = params.get("auth");
console.log("RecoverPassword useEffect - authToken:", authToken);
setTokenUser(authToken || ""); setTokenUser(authToken || "");
history.pushState(null, document.title, "/changepwd"); history.pushState(null, document.title, "/changepwd");

@ -22,7 +22,7 @@ import { ModalInfoWhyCantCreate } from "./ModalInfoWhyCantCreate";
import { ConfirmLeaveModal } from "./ConfirmLeaveModal"; import { ConfirmLeaveModal } from "./ConfirmLeaveModal";
import { checkQuestionHint } from "@utils/checkQuestionHint"; import { checkQuestionHint } from "@utils/checkQuestionHint";
import AmoTokenExpiredDialog from "../IntegrationsPage/IntegrationsModal/Amo/AmoTokenExpiredDialog"; import AmoTokenExpiredDialog from "../IntegrationsPage/IntegrationsModal/Amo/AmoTokenExpiredDialog";
import { useAmoAccount } from "@/api/integration"; import pinkScrollbar from "@utils/pinkScrollbar";
interface Props { interface Props {
openBranchingPage: boolean; openBranchingPage: boolean;
@ -121,6 +121,7 @@ export default function EditPage({
}} }}
> >
<Box <Box
id="lol"
onScroll={onScroll} onScroll={onScroll}
ref={mainBlock} ref={mainBlock}
sx={{ sx={{
@ -140,6 +141,7 @@ export default function EditPage({
boxSizing: "border-box", boxSizing: "border-box",
transition: "transform 0.3s", transition: "transform 0.3s",
paddingBottom: scrollDown && isMobile ? "120px" : undefined, paddingBottom: scrollDown && isMobile ? "120px" : undefined,
...pinkScrollbar(theme)
}} }}
> >
{quizConfig && ( {quizConfig && (

@ -22,6 +22,7 @@ import {
MenuItem, MenuItem,
Select, Select,
Skeleton, Skeleton,
CircularProgress,
Tooltip, Tooltip,
Typography, Typography,
useMediaQuery, useMediaQuery,
@ -35,7 +36,7 @@ import QuestionTimerSettings from "./QuestionTimerSettings";
import SelectableButton from "@ui_kit/SelectableButton"; import SelectableButton from "@ui_kit/SelectableButton";
import { StartPagePreview } from "@ui_kit/StartPagePreview"; import { StartPagePreview } from "@ui_kit/StartPagePreview";
import { resizeFavIcon } from "@ui_kit/reactImageFileResizer"; import { resizeFavIcon } from "@ui_kit/reactImageFileResizer";
import { useState } from "react"; import { useEffect, useState } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import FaviconDropZone from "../FaviconDropZone"; import FaviconDropZone from "../FaviconDropZone";
import ModalSizeImage from "../ModalSizeImage"; import ModalSizeImage from "../ModalSizeImage";
@ -69,23 +70,37 @@ export default function StartPageSettings() {
if (!quiz) return null; 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) { async function handleVideoUpload(videoUrl: string) {
if (!quiz) return; if (!quiz) return;
setBackgroundUploading(true); console.log("[StartPage] Video upload start", { videoUrl });
if (videoUrl.startsWith("blob:")) { if (videoUrl.startsWith("blob:")) {
setBackgroundUploading(true);
const videoBlob = await (await fetch(videoUrl)).blob(); 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) => { uploadQuizImage(quiz.id, videoBlob, (quiz, url) => {
quiz.config.startpage.background.video = url; quiz.config.startpage.background.video = url;
console.log("[StartPage] Backend returned video URL", { url });
}); });
} else { } else {
// для ссылок скелетон не нужен
setBackgroundUploading(false);
updateQuiz(quiz.id, (quiz) => { updateQuiz(quiz.id, (quiz) => {
quiz.config.startpage.background.video = videoUrl; 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; const designType = quiz?.config?.startpageType;
@ -376,14 +391,7 @@ export default function StartPageSettings() {
setBackgroundUploading(false); setBackgroundUploading(false);
}} }}
onImageSaveClick={async (file) => {
setBackgroundUploading(true);
await uploadQuizImage(quiz.id, file, (quiz, url) => {
quiz.config.startpage.background.desktop = url;
});
setBackgroundUploading(false);
}}
onDeleteClick={() => { onDeleteClick={() => {
updateQuiz(quiz.id, (quiz) => { updateQuiz(quiz.id, (quiz) => {
quiz.config.startpage.background.desktop = null; quiz.config.startpage.background.desktop = null;
@ -433,11 +441,12 @@ export default function StartPageSettings() {
</Box> </Box>
{backgroundUploding ? ( {backgroundUploding ? (
<Skeleton <Skeleton
variant="rounded"
sx={{ sx={{
width: "48px", width: "300px",
height: "48px", height: "168px",
transform: "none", 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 <VideoElement
videoSrc={quiz.config.startpage.background.video} videoSrc={quiz.config.startpage.background.video}
theme={theme} theme={theme}
onLoaded={() => {
console.log('[StartPage] VideoElement reported loaded');
setBackgroundUploading(false);
}}
showSkeleton={backgroundUploding}
onDeleteClick={() => { onDeleteClick={() => {
updateQuiz(quiz.id, (quiz) => { updateQuiz(quiz.id, (quiz) => {
quiz.config.startpage.background.video = null; 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> </Box>
)} )}
</> </>
@ -570,14 +600,7 @@ export default function StartPageSettings() {
setLogoUploading(false); setLogoUploading(false);
}} }}
onImageSaveClick={async (file) => {
setLogoUploading(true);
await uploadQuizImage(quiz.id, file, (quiz, url) => {
quiz.config.startpage.logo = url;
});
setLogoUploading(false);
}}
onDeleteClick={() => { onDeleteClick={() => {
updateQuiz(quiz.id, (quiz) => { updateQuiz(quiz.id, (quiz) => {
quiz.config.startpage.logo = null; quiz.config.startpage.logo = null;
@ -650,14 +673,7 @@ export default function StartPageSettings() {
setLogoUploading(false); setLogoUploading(false);
}} }}
onImageSaveClick={async (file) => {
setLogoUploading(true);
await uploadQuizImage(quiz.id, file, (quiz, url) => {
quiz.config.startpage.logo = url;
});
setLogoUploading(false);
}}
onDeleteClick={() => { onDeleteClick={() => {
updateQuiz(quiz.id, (quiz) => { updateQuiz(quiz.id, (quiz) => {
quiz.config.startpage.logo = null; quiz.config.startpage.logo = null;

@ -1,5 +1,6 @@
import Box from "@mui/material/Box"; 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 DeleteIcon from "@mui/icons-material/Delete";
import { IconButton, SxProps, Theme } from "@mui/material"; import { IconButton, SxProps, Theme } from "@mui/material";
import { QuizVideo } from "@frontend/squzanswerer"; import { QuizVideo } from "@frontend/squzanswerer";
@ -10,6 +11,8 @@ type VideoElementProps = {
theme: Theme; theme: Theme;
onDeleteClick: () => void; onDeleteClick: () => void;
deleteIconSx?: SxProps<Theme>; deleteIconSx?: SxProps<Theme>;
onLoaded?: () => void;
showSkeleton?: boolean;
}; };
export const VideoElement: FC<VideoElementProps> = ({ export const VideoElement: FC<VideoElementProps> = ({
@ -18,10 +21,62 @@ export const VideoElement: FC<VideoElementProps> = ({
theme, theme,
onDeleteClick, onDeleteClick,
deleteIconSx, 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 ( return (
<Box sx={{ position: "relative", width: `${width}px` }}> <Box ref={containerRef} sx={{ position: "relative", width: `${width}px`, minHeight: "168px" }}>
<QuizVideo videoUrl={videoSrc} /> <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 <IconButton
onClick={onDeleteClick} onClick={onDeleteClick}
sx={{ sx={{

@ -327,7 +327,8 @@ export const updateQuestion = async <T = AnyTypedQuizQuestion>(
requestQueue.enqueue(`updateQuestion-${questionId}`, request); requestQueue.enqueue(`updateQuestion-${questionId}`, request);
}; };
export const addQuestionVariant = (questionId: string) => { export const addQuestionVariant = (questionId: string): string => {
const newVariant = createQuestionVariant();
updateQuestion(questionId, (question) => { updateQuestion(questionId, (question) => {
switch (question.type) { switch (question.type) {
case "variant": case "variant":
@ -335,12 +336,13 @@ export const addQuestionVariant = (questionId: string) => {
case "select": case "select":
case "images": case "images":
case "varimg": case "varimg":
question.content.variants.push(createQuestionVariant()); question.content.variants.push(newVariant);
break; break;
default: default:
throw new Error(`Cannot add variant to question of type "${question.type}"`); throw new Error(`Cannot add variant to question of type "${question.type}"`);
} }
}); });
return newVariant.id;
}; };
export const addQuestionOwnVariant = (questionId: string) => { export const addQuestionOwnVariant = (questionId: string) => {
updateQuestion(questionId, (question) => { updateQuestion(questionId, (question) => {

@ -8,13 +8,20 @@ import { useQuestionsStore } from "./store";
import { useCurrentQuiz } from "@root/quizes/hooks"; import { useCurrentQuiz } from "@root/quizes/hooks";
import { useEffect } from "react"; import { useEffect } from "react";
export function useQuestions() { export function useQuestions({ quizId }: { quizId?: number } = {}) {
const quiz = useCurrentQuiz(); const currentQuiz = useCurrentQuiz();
const { isLoading, error, isValidating } = useSWR( const currentQuizId = quizId ?? currentQuiz?.backendId;
["questions", quiz?.backendId],
const { data, isLoading, error, isValidating } = useSWR(
currentQuizId ? ["questions", currentQuizId] : null,
([, id]) => questionApi.getList({ quiz_id: id }), ([, id]) => questionApi.getList({ quiz_id: id }),
{ {
onSuccess: ([questions]) => setQuestions(questions), onSuccess: (data) => {
// Добавляем проверку на существование данных
if (data && Array.isArray(data[0])) {
setQuestions(data[0]);
}
},
onError: (error) => { onError: (error) => {
const message = isAxiosError<string>(error) const message = isAxiosError<string>(error)
? error.response?.data ?? "" ? error.response?.data ?? ""
@ -25,7 +32,13 @@ export function useQuestions() {
}, },
}, },
); );
const questions = useQuestionsStore((state) => state.questions); 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 = () => { export const clearUserData = () => {
console.log("clearUserData: Clearing user data");
console.log("clearUserData: Before clearing -", useUserStore.getState());
useUserStore.setState({ ...initialState }); useUserStore.setState({ ...initialState });
console.log("clearUserData: After clearing -", useUserStore.getState());
// Также очищаем localStorage напрямую // Также очищаем localStorage напрямую
localStorage.removeItem("user"); localStorage.removeItem("user");
console.log("clearUserData: localStorage cleared");
}; };
export const setUserAccount = (userAccount: OriginalUserAccount) => export const setUserAccount = (userAccount: OriginalUserAccount) =>

@ -17,12 +17,12 @@ interface CustomTextFieldProps {
value?: string; value?: string;
error?: string; error?: string;
emptyError?: boolean; emptyError?: boolean;
onChange?: (event: ChangeEvent<HTMLInputElement>) => void; onChange?: (event: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => void;
onKeyDown?: (event: KeyboardEvent<HTMLInputElement>) => void; onKeyDown?: (event: KeyboardEvent<HTMLInputElement | HTMLTextAreaElement>) => void;
onBlur?: (event: FocusEvent<HTMLInputElement>) => void; onBlur?: (event: FocusEvent<HTMLInputElement | HTMLTextAreaElement>) => void;
onFocus?: (event: FocusEvent<HTMLInputElement>) => void; onFocus?: (event: FocusEvent<HTMLInputElement | HTMLTextAreaElement>) => void;
onClick?: (event: MouseEvent<HTMLInputElement>) => void; onClick?: (event: MouseEvent<HTMLInputElement | HTMLTextAreaElement>) => void;
onPaste?: (event: ClipboardEvent<HTMLInputElement>) => void; onPaste?: (event: ClipboardEvent<HTMLInputElement | HTMLTextAreaElement>) => void;
text?: string; text?: string;
maxLength?: number; maxLength?: number;
sx?: SxProps<Theme>; sx?: SxProps<Theme>;
@ -32,7 +32,7 @@ interface CustomTextFieldProps {
rows?: number; rows?: number;
className?: string; className?: string;
disabled?: boolean; disabled?: boolean;
inputRef?: Ref<HTMLInputElement>; inputRef?: Ref<HTMLInputElement | HTMLTextAreaElement>;
} }
export default function CustomTextField({ export default function CustomTextField({
@ -67,7 +67,9 @@ export default function CustomTextField({
setInputValue(value); setInputValue(value);
}, [value]); }, [value]);
const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => { const handleInputChange = (
event: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
) => {
if (event.target.value.length <= maxLength) { if (event.target.value.length <= maxLength) {
const inputValue = event.target.value; 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); setIsInputActive(true);
if (onFocus) onFocus(event); if (onFocus) onFocus(event);
}; };
const handleInputBlur = (event: React.FocusEvent<HTMLInputElement>) => { const handleInputBlur = (
event: React.FocusEvent<HTMLInputElement | HTMLTextAreaElement>
) => {
setIsInputActive(false); setIsInputActive(false);
if (onBlur) { if (onBlur) {
@ -98,6 +104,12 @@ export default function CustomTextField({
} }
}; };
const mergedInputElementProps = {
...(InputProps?.inputProps as any),
onClick,
onPaste,
};
return ( return (
<FormControl <FormControl
fullWidth fullWidth
@ -126,14 +138,13 @@ export default function CustomTextField({
onFocus={handleInputFocus} onFocus={handleInputFocus}
onBlur={handleInputBlur} onBlur={handleInputBlur}
onKeyDown={onKeyDown} onKeyDown={onKeyDown}
onClick={onClick} multiline
onPaste={onPaste} rows={rows > 0 ? rows : 1}
multiline={rows > 0}
rows={rows}
disabled={disabled} disabled={disabled}
disableUnderline disableUnderline
inputRef={inputRef} inputRef={inputRef}
{...InputProps} {...InputProps}
inputProps={mergedInputElementProps}
sx={{ sx={{
maxLength: maxLength, maxLength: maxLength,
borderRadius: "10px", borderRadius: "10px",
@ -143,6 +154,8 @@ export default function CustomTextField({
border: `${isInputActive ? "black 2px" : "#9A9AAF 1px"} solid`, border: `${isInputActive ? "black 2px" : "#9A9AAF 1px"} solid`,
backgroundColor: theme.palette.background.default, backgroundColor: theme.palette.background.default,
height: "48px", height: "48px",
// Prevent resize handle to keep visuals unchanged
'& textarea': { resize: 'none' },
...sx, ...sx,
}} }}
data-cy="textfield" data-cy="textfield"

@ -68,14 +68,17 @@ export const MediaSelectionAndDisplay: FC<Iprops> = ({
} }
async function handleVideoUpload(videoUrl: string) { async function handleVideoUpload(videoUrl: string) {
console.log("[QuestionMedia] Video upload start", { videoUrl });
setBackgroundUploading(true); setBackgroundUploading(true);
if (videoUrl.startsWith("blob:")) { if (videoUrl.startsWith("blob:")) {
const videoBlob = await (await fetch(videoUrl)).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) => { uploadQuestionImage(question.id, quizQid, videoBlob, (question, url) => {
if (!("video" in question.content)) return; if (!("video" in question.content)) return;
question.content.video = url; question.content.video = url;
console.log("[QuestionMedia] Backend returned video URL", { url });
}); });
} else { } else {
updateQuestion(question.id, (question) => { updateQuestion(question.id, (question) => {
@ -83,9 +86,10 @@ export const MediaSelectionAndDisplay: FC<Iprops> = ({
question.content.video = videoUrl; 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 ( return (

@ -6,26 +6,15 @@ export default function PrivateRoute() {
const user = useUserStore((state) => state.user); const user = useUserStore((state) => state.user);
const userId = useUserStore((state) => state.userId); 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(() => { useEffect(() => {
if (!user) { if (!user) {
console.log("PrivateRoute: User is null, redirecting to / via useEffect");
window.location.href = "/"; window.location.href = "/";
} }
}, [user]); }, [user]);
if (!user) { if (!user) {
console.log("PrivateRoute: User is null, showing fallback");
return <></>; return <></>;
} }
console.log("PrivateRoute: User exists, rendering Outlet");
return <Outlet />; return <Outlet />;
} }

@ -18,8 +18,6 @@ export const generateHubWalletRequestURL = ({
let currentDomain = window.location.host; let currentDomain = window.location.host;
if (currentDomain === "localhost") currentDomain += ":3000"; if (currentDomain === "localhost") currentDomain += ":3000";
console.log("Я здесь для отладки и спешу сообщить, что деплой был успешно завершен!")
// Используем более надежный способ генерации URL // Используем более надежный способ генерации URL
const baseUrl = `http://${isTestServer ? "s" : ""}hub.pena.digital/anyservicepayment`; const baseUrl = `http://${isTestServer ? "s" : ""}hub.pena.digital/anyservicepayment`;
const params = new URLSearchParams({ const params = new URLSearchParams({

@ -25,10 +25,8 @@ export function handleComponentError(error: Error, info: ErrorInfo, getTickets:
if (!getAuthToken()) return; if (!getAuthToken()) return;
// Проверяем разрешение на отправку ошибок (по домену) // Проверяем разрешение на отправку ошибок (по домену)
if (!isErrorReportingAllowed(error)) { if (!isErrorReportingAllowed(error)) {
console.log('❌ Отправка ошибки заблокирована:', error.message);
return; return;
} }
console.log(`✅ Обработка ошибки: ${error.message}`);
// Копируем __forceSend если есть // Копируем __forceSend если есть
const componentError: ComponentError & { __forceSend?: boolean } = { const componentError: ComponentError & { __forceSend?: boolean } = {
timestamp: Math.floor(Date.now() / 1000), timestamp: Math.floor(Date.now() / 1000),
@ -56,7 +54,6 @@ export async function sendErrorsToServer(getTickets: () => Ticket[]) {
// Если хотя бы одна ошибка в очереди с __forceSend, отправляем всё // Если хотя бы одна ошибка в очереди с __forceSend, отправляем всё
const forceSend = errorsQueue.some(e => (e as any).__forceSend); const forceSend = errorsQueue.some(e => (e as any).__forceSend);
if (!forceSend && !isErrorReportingAllowed()) { if (!forceSend && !isErrorReportingAllowed()) {
console.log('❌ Отправка ошибок заблокирована, очищаем очередь');
errorsQueue = []; errorsQueue = [];
return; return;
} }
@ -93,10 +90,8 @@ export async function sendErrorsToServer(getTickets: () => Ticket[]) {
// Ищет существующий тикет с system: true // Ищет существующий тикет с system: true
export async function findSystemTicket(tickets: Ticket[]) { export async function findSystemTicket(tickets: Ticket[]) {
for (const ticket of tickets) { for (const ticket of tickets) {
console.log("[findSystemTicket] Проверяем тикет:", ticket);
if (!('messages' in ticket)) { if (!('messages' in ticket)) {
if (ticket.top_message && ticket.top_message.system === true) { if (ticket.top_message && ticket.top_message.system === true) {
console.log("[findSystemTicket] Найден тикет по top_message.system:true:", ticket.id);
return 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,20 +25,28 @@ export function useAnalytics({ ready, quizId, to, from }: useAnalyticsProps) {
const [devices, setDevices] = useState<DevicesResponse | null>(null); const [devices, setDevices] = useState<DevicesResponse | null>(null);
const [general, setGeneral] = useState<GeneralResponse | null>(null); const [general, setGeneral] = useState<GeneralResponse | null>(null);
const [questions, setQuestions] = useState<QuestionsResponse | null>(null); const [questions, setQuestions] = useState<QuestionsResponse | null>(null);
const [isLoading, setIsLoading] = useState<boolean>(true);
useEffect(() => { useEffect(() => {
if (!quizId || !ready) return; if (!quizId || !ready) {
setIsLoading(true);
const requestStatistics = async () => {
if (!formatTo || !formatFrom) {
return; return;
} }
const requestStatistics = async () => {
if (!formatTo || !formatFrom) {
setIsLoading(true);
return;
}
setIsLoading(true);
try {
const [gottenGeneral] = await getGeneral(quizId, formatTo, formatFrom); const [gottenGeneral] = await getGeneral(quizId, formatTo, formatFrom);
const [gottenDevices] = await getDevices(quizId, formatTo, formatFrom); const [gottenDevices] = await getDevices(quizId, formatTo, formatFrom);
const [gottenQuestions] = await getQuestions(quizId, formatTo, formatFrom); const [gottenQuestions] = await getQuestions(quizId, formatTo, formatFrom);
getGraphics(quizId, formatTo, formatFrom) getGraphics(quizId, formatTo, formatFrom);
if (gottenGeneral) { if (gottenGeneral) {
setGeneral(gottenGeneral); setGeneral(gottenGeneral);
@ -51,10 +59,13 @@ export function useAnalytics({ ready, quizId, to, from }: useAnalyticsProps) {
if (gottenQuestions) { if (gottenQuestions) {
setQuestions(gottenQuestions); setQuestions(gottenQuestions);
} }
} finally {
setIsLoading(false);
}
}; };
requestStatistics(); requestStatistics();
}, [ready, quizId, to, from]); }, [ready, quizId, to, from]);
return { devices, general, questions }; return { devices, general, questions, isLoading };
} }

@ -35,21 +35,10 @@ export const useAfterPay = () => {
// Проверяем, есть ли токен восстановления пароля в URL // Проверяем, есть ли токен восстановления пароля в URL
const hasAuthToken = searchParams.get("auth") || window.location.search.includes("auth="); 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 // НЕ очищаем параметры на странице восстановления пароля, когда открыты модалки или есть токен auth
if (location.pathname !== "/changepwd" && !location.state?.backgroundLocation && !isRecoverPasswordModal && !hasAuthToken) { if (location.pathname !== "/changepwd" && !location.state?.backgroundLocation && !isRecoverPasswordModal && !hasAuthToken) {
console.log("Очищаем параметры URL");
setSearchParams({}, { replace: true }); setSearchParams({}, { replace: true });
} else {
console.log("НЕ очищаем параметры URL");
} }
if (userId && URLuserId && userId === URLuserId) { 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
};
};