запер и постбек подключены к api + мобилка

This commit is contained in:
Nastya 2025-09-03 06:03:45 +03:00
parent 8a4e980ed4
commit 751d9eb4f3
6 changed files with 352 additions and 137 deletions

@ -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";
@ -45,7 +46,7 @@ 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: Boolean(Object.keys(quiz).length),
quizId: editQuizId?.toString() || "", quizId: editQuizId?.toString() || "",
from, from,
@ -255,12 +256,18 @@ export default function Analytics() {
{isMobile ? <ResetIcon /> : "Сбросить"} {isMobile ? <ResetIcon /> : "Сбросить"}
</Button> </Button>
</Box> </Box>
<General {isLoading ? (
data={general} <AnalyticsSkeleton />
day={86400 - moment(to).unix() - moment(from).unix() > 0} ) : (
/> <>
<AnswersStatistics data={questions} /> <General
<Devices data={devices} /> data={general}
day={86400 - moment(to).unix() - moment(from).unix() > 0}
/>
<AnswersStatistics data={questions} />
<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>
);
}

@ -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,10 +1,12 @@
import { FC, useState } from "react"; import { FC, useState, useEffect } from "react";
import { Dialog, IconButton, Typography, useMediaQuery, useTheme, Box, Button, Link } 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 CustomTextField from "@/ui_kit/CustomTextField";
import OrangeYoutube from "@/assets/icons/OrangeYoutube"; import { createLeadTarget, getLeadTargetsByQuiz, deleteLeadTarget } from "@/api/leadtarget";
import { createLeadTarget } from "@/api/leadtarget"; import { useFormik } from "formik";
import InstructionYoutubeLink from "@/pages/IntegrationsPage/IntegrationsModal/InstructionYoutubeLink";
import { useSnackbar } from "notistack";
type PostbackModalProps = { type PostbackModalProps = {
isModalOpen: boolean; isModalOpen: boolean;
@ -22,8 +24,60 @@ 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 [token, setToken] = useState<string>(""); const [isSaving, setIsSaving] = useState<boolean>(false);
const [domain, setDomain] = useState<string>(""); const { enqueueSnackbar } = useSnackbar();
const deleteLeadTargetsByQuizType = async (quizId: number, type: "webhook") => {
const [targets] = await getLeadTargetsByQuiz(quizId);
if (!targets || targets.length === 0) {
console.log("No targets found for deletion");
return;
}
const toDelete = targets.filter(t => t.Type === type);
console.log("Targets to delete:", toDelete);
for (const t of toDelete) {
console.log("Deleting target with ID:", t.ID);
await deleteLeadTarget(t.ID);
}
};
const handleSubmit = async (values: { token: string; domain: string }) => {
const tokenValue = (values.token || "").trim();
const target = (values.domain || "").trim();
try {
setIsSaving(true);
if (!tokenValue && !target) {
await deleteLeadTargetsByQuizType(quiz.backendId, "webhook");
enqueueSnackbar("Postback удален", { variant: "success" });
} else {
await createLeadTarget({
type: "webhook",
quizID: quiz.backendId,
target,
name: tokenValue || undefined,
});
enqueueSnackbar("Postback сохранен", { variant: "success" });
}
} catch (error) {
enqueueSnackbar("Ошибка при сохранении", { variant: "error" });
} finally {
setIsSaving(false);
}
};
const formik = useFormik<{ token: string; domain: string }>({
initialValues: { token: "", domain: "" },
onSubmit: handleSubmit,
});
useEffect(() => {
if (isModalOpen) {
setTimeout(() => {
const input = document.getElementById("postback-domain") as HTMLInputElement | null;
input?.focus();
}, 0);
}
}, [isModalOpen]);
return ( return (
<Dialog <Dialog
@ -33,7 +87,8 @@ export const PostbackModal: FC<PostbackModalProps> = ({
PaperProps={{ PaperProps={{
sx: { sx: {
maxWidth: isTablet ? "100%" : "919px", maxWidth: isTablet ? "100%" : "919px",
height: "314px", height: isMobile ? "303px" : "214px",
// height: "314px",
borderRadius: "12px", borderRadius: "12px",
}, },
}} }}
@ -69,63 +124,30 @@ export const PostbackModal: FC<PostbackModalProps> = ({
overflow: "auto", overflow: "auto",
}} }}
> >
<Box {!isMobile && <InstructionYoutubeLink />}
sx={{
display: "flex",
justifyContent: "right"
}}>
<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>
<Box <Box
component="form"
onSubmit={formik.handleSubmit}
sx={{ sx={{
marginTop: "-43px", marginTop: isMobile ? 0 : "-43px",
display: "inline-flex", display: isMobile ? "flex" : "inline-flex",
alignItems: "end", flexDirection: isMobile ? "column" : "row",
gap: "38px", alignItems: isMobile ? "center" : "end",
gap: isMobile ? "10px" : "38px",
width: "100%" width: "100%"
}} }}
> >
<Box <Box
sx={{ sx={{
width: "100%" width: "100%"
}} }}
> >
<Typography <Typography
sx={{ sx={{
fontWeight: 500, fontWeight: 500,
color: "black", color: "black",
mt: "11px", mt: isMobile ? 0 : "11px",
mb: "14px",
width: "100%"
}}
>
Токен авторизации
</Typography>
<CustomTextField
id="postback-auth-token"
placeholder="токен в формате ХХХХХХ"
value={token}
onChange={(e) => setToken(e.target.value)}
maxLength={150}
/>
<Typography
sx={{
fontWeight: 500,
color: "black",
mt: "11px",
mb: "14px", mb: "14px",
}} }}
> >
@ -134,30 +156,21 @@ export const PostbackModal: FC<PostbackModalProps> = ({
<CustomTextField <CustomTextField
id="postback-domain" id="postback-domain"
placeholder="токен в формате ХХХХХХ" placeholder="токен в формате ХХХХХХ"
value={domain} value={formik.values.domain}
onChange={(e) => setDomain(e.target.value)} onChange={(e) => formik.setFieldValue("domain", e.target.value)}
maxLength={150} maxLength={150}
/> />
</Box> </Box>
<Button <Button
onClick={async () => { disabled={isSaving}
const target = domain.trim(); type="submit"
if (!token.trim() || !target) return;
// Отправляем webhook postback как целевой endpoint
await createLeadTarget({
type: "webhook",
quizID: quiz.backendId,
target,
name: token.trim(),
});
}}
variant="contained" variant="contained"
sx={{ sx={{
backgroundColor: "#7E2AEA", backgroundColor: "#7E2AEA",
fontSize: "18px", fontSize: "18px",
lineHeight: "18px", lineHeight: "18px",
width: "216px", width: isMobile ? "100%" : "216px",
height: "44px", height: "44px",
p: "10px 20px", p: "10px 20px",
@ -166,6 +179,7 @@ export const PostbackModal: FC<PostbackModalProps> = ({
Сохранить Сохранить
</Button> </Button>
</Box> </Box>
{isMobile && <InstructionYoutubeLink sx={{ mt: "40px", justifyContent: "center" }} />}
</Box> </Box>
</Dialog> </Dialog>
); );

@ -1,10 +1,12 @@
import { FC, useState } from "react"; import { FC, useState, useEffect } from "react";
import { Dialog, IconButton, Typography, useMediaQuery, useTheme, Box, Link, Button } 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 OrangeYoutube from "@/assets/icons/OrangeYoutube";
import CustomTextField from "@/ui_kit/CustomTextField"; import CustomTextField from "@/ui_kit/CustomTextField";
import { createLeadTarget } from "@/api/leadtarget"; import { createLeadTarget, getLeadTargetsByQuiz, deleteLeadTarget } from "@/api/leadtarget";
import { useFormik } from "formik";
import InstructionYoutubeLink from "@/pages/IntegrationsPage/IntegrationsModal/InstructionYoutubeLink";
import { useSnackbar } from "notistack";
type ZapierModalProps = { type ZapierModalProps = {
isModalOpen: boolean; isModalOpen: boolean;
@ -22,7 +24,58 @@ 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 [webhookUrl, setWebhookUrl] = useState<string>(""); const [isSaving, setIsSaving] = useState<boolean>(false);
const { enqueueSnackbar } = useSnackbar();
const deleteLeadTargetsByQuizType = async (quizId: number, type: "webhook") => {
const [targets] = await getLeadTargetsByQuiz(quizId);
if (!targets || targets.length === 0) {
console.log("No targets found for deletion");
return;
}
const toDelete = targets.filter(t => t.Type === type);
console.log("Targets to delete:", toDelete);
for (const t of toDelete) {
console.log("Deleting target with ID:", t.ID);
await deleteLeadTarget(t.ID);
}
};
const handleSubmit = async (values: { webhookUrl: string }) => {
const target = (values.webhookUrl || "").trim();
try {
setIsSaving(true);
if (!target) {
await deleteLeadTargetsByQuizType(quiz.backendId, "webhook");
enqueueSnackbar("Webhook удален", { variant: "success" });
} else {
await createLeadTarget({
type: "webhook",
quizID: quiz.backendId,
target,
});
enqueueSnackbar("Webhook сохранен", { variant: "success" });
}
} catch (error) {
enqueueSnackbar("Ошибка при сохранении", { variant: "error" });
} finally {
setIsSaving(false);
}
};
const formik = useFormik<{ webhookUrl: string }>({
initialValues: { webhookUrl: "" },
onSubmit: handleSubmit,
});
useEffect(() => {
if (isModalOpen) {
setTimeout(() => {
const input = document.getElementById("zapier-webhook-url") as HTMLInputElement | null;
input?.focus();
}, 0);
}
}, [isModalOpen]);
return ( return (
<Dialog <Dialog
@ -32,7 +85,7 @@ export const ZapierModal: FC<ZapierModalProps> = ({
PaperProps={{ PaperProps={{
sx: { sx: {
maxWidth: isTablet ? "100%" : "919px", maxWidth: isTablet ? "100%" : "919px",
height: "195px", height: isMobile ? "303px" : "195px",
borderRadius: "12px", borderRadius: "12px",
}, },
}} }}
@ -62,6 +115,7 @@ export const ZapierModal: FC<ZapierModalProps> = ({
<CloseIcon /> <CloseIcon />
</IconButton> </IconButton>
</Box> </Box>
<Box <Box
sx={{ sx={{
padding: "20px", padding: "20px",
@ -69,69 +123,59 @@ export const ZapierModal: FC<ZapierModalProps> = ({
overflow: "auto", overflow: "auto",
}} }}
> >
{!isMobile && <InstructionYoutubeLink />}
<Box <Box
sx={{ sx={{
display: "flex", marginTop: isMobile ? 0 : "-33px",
justifyContent: "right" display: isMobile ? "flex" : "block",
}}> flexDirection: "column",
<Link alignItems: "center"
href="https://youtube.com" }}
underline="hover"
sx={{
color: "#FA590B",
display: "inline-flex",
gap: "10px",
fontSize: "16px"
}}
><OrangeYoutube sx={{
width: "24px",
height: "24px",
}} /> Смотреть инструкцию</Link>
</Box>
<Box
sx={{ marginTop: "-33px" }}
> >
<Box
<Typography
sx={{ sx={{
fontWeight: 500, width: "100%"
color: "black",
mt: "11px",
mb: "14px",
}} }}
> >
URL webhook <Typography
</Typography> sx={{
fontWeight: 500,
color: "black",
mt: isMobile ? 0 : "11px",
mb: "14px",
}}
>
URL webhook
</Typography>
</Box>
<Box <Box
sx={{ component="form"
display: "inline-flex", onSubmit={formik.handleSubmit}
width: "100%", sx={{
gap: "38px" display: "inline-flex",
}} flexDirection: isMobile ? "column" : "row",
width: "100%",
gap: isMobile ? "10px" : "38px"
}}
> >
<CustomTextField <CustomTextField
id="zapier-webhook-url" id="zapier-webhook-url"
placeholder="введите url здесь" placeholder="введите url здесь"
value={webhookUrl} value={formik.values.webhookUrl}
onChange={(e) => setWebhookUrl(e.target.value)} onChange={(e) => formik.setFieldValue("webhookUrl", e.target.value)}
maxLength={150} maxLength={150}
/> />
<Button <Button
onClick={async () => { disabled={isSaving}
const target = webhookUrl.trim(); type="submit"
if (!target) return;
await createLeadTarget({
type: "webhook",
quizID: quiz.backendId,
target,
});
}}
variant="contained" variant="contained"
sx={{ sx={{
backgroundColor: "#7E2AEA", backgroundColor: "#7E2AEA",
fontSize: "18px", fontSize: "18px",
lineHeight: "18px", lineHeight: "18px",
width: "216px", width: isMobile ? "100%" : "216px",
height: "44px", height: "44px",
p: "10px 20px", p: "10px 20px",
@ -141,6 +185,7 @@ export const ZapierModal: FC<ZapierModalProps> = ({
</Button> </Button>
</Box> </Box>
{isMobile && <InstructionYoutubeLink sx={{ mt: "40px" }} />}
</Box> </Box>
</Box> </Box>
</Box> </Box>

@ -25,36 +25,47 @@ 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);
return;
}
const requestStatistics = async () => { const requestStatistics = async () => {
if (!formatTo || !formatFrom) { if (!formatTo || !formatFrom) {
setIsLoading(true);
return; return;
} }
const [gottenGeneral] = await getGeneral(quizId, formatTo, formatFrom); setIsLoading(true);
const [gottenDevices] = await getDevices(quizId, formatTo, formatFrom);
const [gottenQuestions] = await getQuestions(quizId, formatTo, formatFrom);
getGraphics(quizId, formatTo, formatFrom) try {
const [gottenGeneral] = await getGeneral(quizId, formatTo, formatFrom);
const [gottenDevices] = await getDevices(quizId, formatTo, formatFrom);
const [gottenQuestions] = await getQuestions(quizId, formatTo, formatFrom);
if (gottenGeneral) { getGraphics(quizId, formatTo, formatFrom);
setGeneral(gottenGeneral);
}
if (gottenDevices) { if (gottenGeneral) {
setDevices(gottenDevices); setGeneral(gottenGeneral);
} }
if (gottenQuestions) { if (gottenDevices) {
setQuestions(gottenQuestions); setDevices(gottenDevices);
}
if (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 };
} }