рефакторинг тарифов

This commit is contained in:
Nastya 2025-07-11 19:21:36 +03:00
parent 572dfae016
commit 66456bac31
17 changed files with 641 additions and 753 deletions

@ -11,8 +11,8 @@ import { useSnackbar } from "notistack";
import { PayModal } from "./PayModal"; import { PayModal } from "./PayModal";
import { useUserStore } from "@/stores/user"; import { useUserStore } from "@/stores/user";
import { cartApi } from "@/api/cart"; import { cartApi } from "@/api/cart";
import { outCart } from "../Tariffs/Tariffs"; import { outCart } from "../Tariffs/utils";
import { inCart } from "../Tariffs/Tariffs"; import { inCart } from "../Tariffs/utils";
import { isTestServer } from "@/utils/hooks/useDomainDefine"; import { isTestServer } from "@/utils/hooks/useDomainDefine";
import { useToken } from "@frontend/kitui"; import { useToken } from "@frontend/kitui";
import { useSWRConfig } from "swr"; import { useSWRConfig } from "swr";

@ -0,0 +1,147 @@
import { Box, useMediaQuery, useTheme } from "@mui/material"
import { NavCard } from "./components/NavCard"
import { createTariffElements } from "./tariffsUtils/createTariffElements"
import SmallIconPena from "@/assets/icons/SmallIconPena"
interface TariffCardDisplaySelectorProps {
content: {
title: string,
onClick: () => void
}[]
selectedItem: TypePages
tariffs: any[]
user: any
discounts: any[]
openModalHC: (tariffInfo: any) => void
userPrivilegies: any
startRequestCreate: () => void
}
export const TariffCardDisplaySelector = ({
content,
selectedItem,
tariffs,
user,
discounts,
openModalHC,
userPrivilegies,
startRequestCreate
}: TariffCardDisplaySelectorProps) => {
const theme = useTheme()
const isTablet = useMediaQuery(theme.breakpoints.down(1000));
const sendRequest = userPrivilegies?.quizManual?.amount > 0 ? startRequestCreate : undefined
switch (selectedItem) {
case "dop":
return <Box
sx={{
display: "flex",
flexWrap: "wrap",
width: "100%"
}}>
{content.map(data => <NavCard {...data} key={data.title} />)}
</Box>
case "hide":
const filteredBadgeTariffs = tariffs.filter((tariff) => {
return (
tariff.privileges[0].serviceKey === "squiz" &&
!tariff.isDeleted &&
!tariff.isCustom &&
tariff.privileges[0].privilegeId === "squizHideBadge" &&
tariff.privileges[0]?.type === "day"
);
});
return createTariffElements(
filteredBadgeTariffs,
false,
user,
discounts,
openModalHC,
)
case "create":
const filteredCreateTariffs = tariffs.filter((tariff) => {
return (
tariff.privileges[0].serviceKey === "squiz" &&
!tariff.isDeleted &&
!tariff.isCustom &&
tariff.privileges[0].privilegeId === "quizManual" &&
tariff.privileges[0]?.type === "count"
);
});
return createTariffElements(
filteredCreateTariffs,
false,
user,
discounts,
openModalHC,
sendRequest,
true,
<SmallIconPena />
)
case "premium":
const filteredPremiumTariffs = tariffs.filter((tariff) => {
return (
tariff.privileges[0].serviceKey === "squiz" &&
!tariff.isDeleted &&
!tariff.isCustom &&
tariff.privileges[0].privilegeId === "squizPremium" &&
tariff.privileges[0]?.type === "day"
);
});
return createTariffElements(
filteredPremiumTariffs,
false,
user,
discounts,
openModalHC,
)
case "analytics":
const filteredAnalyticsTariffs = tariffs.filter((tariff) => {
return (
tariff.privileges[0].serviceKey === "squiz" &&
!tariff.isDeleted &&
!tariff.isCustom &&
tariff.privileges[0].privilegeId === "squizAnalytics" &&
tariff.privileges[0]?.type === "count"
);
});
return createTariffElements(
filteredAnalyticsTariffs,
false,
user,
discounts,
openModalHC,
)
case "custom":
const filteredCustomTariffs = tariffs.filter((tariff) => {
return (
tariff.privileges[0].serviceKey === "squiz" &&
!tariff.isDeleted &&
tariff.isCustom &&
tariff.privileges[0]?.type === "day"
);
});
return createTariffElements(
filteredCustomTariffs,
false,
user,
discounts,
openModalHC,
)
default:
return <Box
sx={{
display: "flex",
flexWrap: "wrap",
width: "100%"
}}>
{content.map(data => <NavCard {...data} key={data.title} />)}
</Box>
}
}

@ -1,39 +1,33 @@
import { activatePromocode } from "@api/promocode"; import { activatePromocode } from "@api/promocode";
import { useToken } from "@frontend/kitui"; import { useToken } from "@frontend/kitui";
import ArrowLeft from "@icons/questionsPage/arrowLeft";
import { import {
Box, Box,
Button,
Container,
IconButton,
Modal,
Paper,
Typography, Typography,
useMediaQuery, useMediaQuery,
useTheme, useTheme,
} from "@mui/material"; } from "@mui/material";
import { useUserStore } from "@root/user"; import { useUserStore } from "@root/user";
import { LogoutButton } from "@ui_kit/LogoutButton";
import { useDomainDefine } from "@utils/hooks/useDomainDefine"; import { useDomainDefine } from "@utils/hooks/useDomainDefine";
import { enqueueSnackbar } from "notistack"; import { enqueueSnackbar } from "notistack";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { withErrorBoundary } from "react-error-boundary"; import { withErrorBoundary } from "react-error-boundary";
import { Link, useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import Logotip from "../../pages/Landing/images/icons/QuizLogo";
import CollapsiblePromocodeField from "./CollapsiblePromocodeField"; import CollapsiblePromocodeField from "./CollapsiblePromocodeField";
import { Tabs } from "./Tabs"; import { Tabs } from "./Tabs";
import { createTariffElements } from "./tariffsUtils/createTariffElements"; import { createTariffElements } from "./tariffsUtils/createTariffElements";
import { currencyFormatter } from "./tariffsUtils/currencyFormatter"; import { currencyFormatter } from "./tariffsUtils/currencyFormatter";
import { useWallet, setCash } from "@root/cash"; import { useWallet, setCash } from "@root/cash";
import { handleLogoutClick } from "@utils/HandleLogoutClick";
import { cartApi } from "@api/cart"; import { cartApi } from "@api/cart";
import { Other } from "./pages/Other"; import { TariffCardDisplaySelector } from "./TariffCardDisplaySelector";
import { ModalRequestCreate } from "./ModalRequestCreate"; import { ModalRequestCreate } from "./ModalRequestCreate";
import { cancelCC, useCC } from "@/stores/cc"; import { cancelCC, useCC } from "@/stores/cc";
import { NavSelect } from "./NavSelect"; import { NavSelect } from "./NavSelect";
import { useTariffs } from '@utils/hooks/useTariffs'; import { useTariffs } from '@utils/hooks/useTariffs';
import { useDiscounts } from '@utils/hooks/useDiscounts'; import { useDiscounts } from '@utils/hooks/useDiscounts';
import { PaymentConfirmationModal } from "./components/PaymentConfirmationModal";
import { TariffsHeader } from "./components/TariffsHeader";
import { inCart, outCart } from "./utils";
const StepperText: Record<string, string> = { const StepperText: Record<string, string> = {
day: "Тарифы на время", day: "Тарифы на время",
@ -50,9 +44,9 @@ function TariffPage() {
const userId = useUserStore((state) => state.userId); const userId = useUserStore((state) => state.userId);
const navigate = useNavigate(); const navigate = useNavigate();
const user = useUserStore((state) => state.customerAccount); const user = useUserStore((state) => state.customerAccount);
const a = useUserStore((state) => state.customerAccount); //c wallet const userWithWallet = useUserStore((state) => state.customerAccount); //c wallet
console.log("________________34563875693785692576_____________USERRRRRRR") console.log("________________34563875693785692576_____________USERRRRRRR")
console.log(a) // console.log(userWithWallet)
const { data: discounts } = useDiscounts(userId); const { data: discounts } = useDiscounts(userId);
const [isRequestCreate, setIsRequestCreate] = useState(false); const [isRequestCreate, setIsRequestCreate] = useState(false);
const [openModal, setOpenModal] = useState({}); const [openModal, setOpenModal] = useState({});
@ -69,13 +63,13 @@ console.log("________34563875693785692576_____ TARIFFS")
console.log(tariffs) console.log(tariffs)
useEffect(() => { useEffect(() => {
if (a) { if (userWithWallet) {
let cs = currencyFormatter.format(Number(user.wallet.cash) / 100); let cs = currencyFormatter.format(Number(user.wallet.cash) / 100);
let cc = Number(user.wallet.cash); let cc = Number(user.wallet.cash);
let cr = Number(user.wallet.cash) / 100; let cr = Number(user.wallet.cash) / 100;
setCash(cs, cc, cr); setCash(cs, cc, cr);
} }
}, [a]); }, [userWithWallet]);
useEffect(() => { useEffect(() => {
if (cc) { if (cc) {
@ -169,63 +163,10 @@ console.log(tariffs)
setIsRequestCreate(true) setIsRequestCreate(true)
} }
if (!a) return null; if (!userWithWallet) return null;
return ( return (
<> <>
<Container <TariffsHeader cashString={cashString} />
component="nav"
disableGutters
maxWidth={false}
sx={{
px: "16px",
display: "flex",
height: "80px",
alignItems: "center",
gap: isMobile ? "7px" : isTablet ? "20px" : "60px",
flexDirection: "row",
justifyContent: "space-between",
bgcolor: "white",
borderBottom: "1px solid #E3E3E3",
}}
>
<Link to="/">
<Logotip width={124} />
</Link>
<IconButton onClick={() => navigate("/list")}>
<ArrowLeft color="black" />
</IconButton>
<Box sx={{ display: "flex", ml: "auto" }}>
<Box sx={{ whiteSpace: "nowrap" }}>
<Typography
sx={{
fontSize: "12px",
lineHeight: "14px",
color: "gray",
}}
>
Мой баланс
</Typography>
<Typography
variant="body2"
color={"#7e2aea"}
fontSize={
isMobile ? (cashString.length > 9 ? "13px" : "16px") : "16px"
}
>
{cashString}
</Typography>
</Box>
<LogoutButton
onClick={() => {
navigate("/");
handleLogoutClick();
}}
sx={{
ml: "20px",
}}
/>
</Box>
</Container>
<Box <Box
sx={{ sx={{
p: "25px", p: "25px",
@ -281,9 +222,9 @@ console.log(tariffs)
discounts, discounts,
openModalHC, openModalHC,
)} )}
{(selectedItem === "dop" || selectedItem === "hide" || selectedItem === "create") {(selectedItem === "hide" || selectedItem === "create" || selectedItem === "premium" || selectedItem === "analytics" || selectedItem === "custom")
&& ( && (
<Other <TariffCardDisplaySelector
selectedItem={selectedItem} selectedItem={selectedItem}
content={[ content={[
{ {
@ -294,8 +235,67 @@ console.log(tariffs)
title: "Создать квиз на заказ", title: "Создать квиз на заказ",
onClick: () => setSelectedItem("create") onClick: () => setSelectedItem("create")
}, },
{
title: "Премиум функции",
onClick: () => setSelectedItem("premium")
},
{
title: "Расширенная аналитика",
onClick: () => setSelectedItem("analytics")
},
{
title: "Кастомные тарифы",
onClick: () => setSelectedItem("custom")
},
]} ]}
tariffs={tariffs}
user={user}
discounts={discounts}
openModalHC={openModalHC}
userPrivilegies={userPrivilegies}
startRequestCreate={startRequestCreate}
/>
)}
{selectedItem === "dop" && (
<TariffCardDisplaySelector
selectedItem={selectedItem}
content={
selectedItem === "dop"
? [
{
title: `Убрать логотип "PenaQuiz"`,
onClick: () => setSelectedItem("hide")
},
{
title: "Создать квиз на заказ",
onClick: () => setSelectedItem("create")
},
]
: [
{
title: `Убрать логотип "PenaQuiz"`,
onClick: () => setSelectedItem("hide")
},
{
title: "Создать квиз на заказ",
onClick: () => setSelectedItem("create")
},
{
title: "Премиум функции",
onClick: () => setSelectedItem("premium")
},
{
title: "Расширенная аналитика",
onClick: () => setSelectedItem("analytics")
},
{
title: "Кастомные тарифы",
onClick: () => setSelectedItem("custom")
},
]
}
tariffs={tariffs} tariffs={tariffs}
user={user} user={user}
discounts={discounts} discounts={discounts}
@ -305,37 +305,12 @@ console.log(tariffs)
/> />
)} )}
</Box> </Box>
<Modal <PaymentConfirmationModal
open={Object.values(openModal).length > 0} open={Object.values(openModal).length > 0}
onClose={() => setOpenModal({})} onClose={() => setOpenModal({})}
> onConfirm={() => tryBuy(openModal)}
<Paper price={openModal.price}
sx={{ />
position: "absolute" as "absolute",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
boxShadow: 24,
p: 4,
display: "flex",
justifyContent: "center",
flexDirection: "column",
}}
>
<Typography
id="modal-modal-title"
variant="h6"
component="h2"
mb="20px"
>
Вы подтверждаете платёж в сумму{" "}
{openModal.price ? openModal.price.toFixed(2) : 0}
</Typography>
<Button variant="contained" onClick={() => tryBuy(openModal)}>
купить
</Button>
</Paper>
</Modal>
<ModalRequestCreate open={isRequestCreate} onClose={() => setIsRequestCreate(false)} /> <ModalRequestCreate open={isRequestCreate} onClose={() => setIsRequestCreate(false)} />
</> </>
); );
@ -364,47 +339,3 @@ const LoadingPage = () => (
</Typography> </Typography>
</Box> </Box>
); );
export const inCart = () => {
let saveCart = JSON.parse(localStorage.getItem("saveCart") || "[]");
if (Array.isArray(saveCart)) {
saveCart.forEach(async (id: string) => {
const [_, addError] = await cartApi.add(id);
if (addError) {
console.error(addError);
} else {
let index = saveCart.indexOf("green");
if (index !== -1) {
saveCart.splice(index, 1);
}
localStorage.setItem("saveCart", JSON.stringify(saveCart));
}
});
} else {
localStorage.setItem("saveCart", "[]");
}
};
export const outCart = (cart: string[]) => {
//Сделаем муторно и подольше, зато при прерывании сессии данные потеряются минимально
if (cart.length > 0) {
cart.forEach(async (id: string) => {
const [_, deleteError] = await cartApi.delete(id);
if (deleteError) {
console.error(deleteError);
cancelCC()//мы хотели открыть модалку после покупки тарифа на создание квиза, но не вышло и модалку не откроем
return;
}
let saveCart = JSON.parse(localStorage.getItem("saveCart") || "[]") || [];
if (!Array.isArray(saveCart)) saveCart = []
saveCart = saveCart.push(id);
localStorage.setItem("saveCart", JSON.stringify(saveCart));
});
}
};

@ -0,0 +1,51 @@
import {
Button,
Modal,
Paper,
Typography,
} from "@mui/material";
interface PaymentConfirmationModalProps {
open: boolean;
onClose: () => void;
onConfirm: () => void;
price: number;
}
export const PaymentConfirmationModal = ({
open,
onClose,
onConfirm,
price,
}: PaymentConfirmationModalProps) => {
return (
<Modal open={open} onClose={onClose}>
<Paper
sx={{
position: "absolute" as "absolute",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
boxShadow: 24,
p: 4,
display: "flex",
justifyContent: "center",
flexDirection: "column",
}}
>
<Typography
id="modal-modal-title"
variant="h6"
component="h2"
mb="20px"
>
Вы подтверждаете платёж в сумму{" "}
{price ? price.toFixed(2) : 0}
</Typography>
<Button variant="contained" onClick={onConfirm}>
купить
</Button>
</Paper>
</Modal>
);
};

@ -0,0 +1,83 @@
import { useToken } from "@frontend/kitui";
import ArrowLeft from "@icons/questionsPage/arrowLeft";
import {
Box,
Container,
IconButton,
Typography,
useMediaQuery,
useTheme,
} from "@mui/material";
import { useUserStore } from "@root/user";
import { LogoutButton } from "@ui_kit/LogoutButton";
import { handleLogoutClick } from "@utils/HandleLogoutClick";
import { Link, useNavigate } from "react-router-dom";
import Logotip from "../../../pages/Landing/images/icons/QuizLogo";
interface TariffsHeaderProps {
cashString: string;
}
export const TariffsHeader = ({ cashString }: TariffsHeaderProps) => {
const theme = useTheme();
const navigate = useNavigate();
const isMobile = useMediaQuery(theme.breakpoints.down(600));
const isTablet = useMediaQuery(theme.breakpoints.down(1000));
return (
<Container
component="nav"
disableGutters
maxWidth={false}
sx={{
px: "16px",
display: "flex",
height: "80px",
alignItems: "center",
gap: isMobile ? "7px" : isTablet ? "20px" : "60px",
flexDirection: "row",
justifyContent: "space-between",
bgcolor: "white",
borderBottom: "1px solid #E3E3E3",
}}
>
<Link to="/">
<Logotip width={124} />
</Link>
<IconButton onClick={() => navigate("/list")}>
<ArrowLeft color="black" />
</IconButton>
<Box sx={{ display: "flex", ml: "auto" }}>
<Box sx={{ whiteSpace: "nowrap" }}>
<Typography
sx={{
fontSize: "12px",
lineHeight: "14px",
color: "gray",
}}
>
Мой баланс
</Typography>
<Typography
variant="body2"
color={"#7e2aea"}
fontSize={
isMobile ? (cashString.length > 9 ? "13px" : "16px") : "16px"
}
>
{cashString}
</Typography>
</Box>
<LogoutButton
onClick={() => {
navigate("/");
handleLogoutClick();
}}
sx={{
ml: "20px",
}}
/>
</Box>
</Container>
);
};

@ -1,97 +0,0 @@
import { Box, useMediaQuery, useTheme } from "@mui/material"
import { NavCard } from "../components/NavCard"
import { createTariffElements } from "../tariffsUtils/createTariffElements"
import SmallIconPena from "@/assets/icons/SmallIconPena"
interface Props {
content: {
title: string,
onClick: () => void
}[]
selectedItem: TypePages
}
export const Other = ({
content,
selectedItem,
tariffs,
user,
discounts,
openModalHC,
userPrivilegies,
startRequestCreate
}: any) => {
const theme = useTheme()
const isTablet = useMediaQuery(theme.breakpoints.down(1000));
const sendRequest = userPrivilegies?.quizManual?.amount > 0 ? startRequestCreate : undefined
switch (selectedItem) {
case "hide":
const filteredBadgeTariffs = tariffs.filter((tariff) => {
return (
tariff.privileges[0].serviceKey === "squiz" &&
!tariff.isDeleted &&
!tariff.isCustom &&
tariff.privileges[0].privilegeId === "squizHideBadge" &&
tariff.privileges[0]?.type === "day"
);
});
return <Box
sx={{
justifyContent: "left",
display: "grid",
gap: "40px",
gridTemplateColumns: `repeat(auto-fit, minmax(300px, ${isTablet ? "436px" : "360px"
}))`,
}}
>
{createTariffElements(
filteredBadgeTariffs,
false,
user,
discounts,
openModalHC,
)}
</Box>
case "create":
const filteredCreateTariffs = tariffs.filter((tariff) => {
return (
tariff.privileges[0].serviceKey === "squiz" &&
!tariff.isDeleted &&
!tariff.isCustom &&
tariff.privileges[0].privilegeId === "quizManual" &&
tariff.privileges[0]?.type === "count"
);
});
return <Box
sx={{
justifyContent: "left",
display: "grid",
gap: "40px",
gridTemplateColumns: `repeat(auto-fit, minmax(300px, ${isTablet ? "436px" : "360px"
}))`,
}}
>
{createTariffElements(
filteredCreateTariffs,
false,
user,
discounts,
openModalHC,
sendRequest,
true,
<SmallIconPena/>
)}
</Box>
default:
return <Box
sx={{
display: "flex",
flexWrap: "wrap",
width: "100%"
}}>
{content.map(data => <NavCard {...data} key={data.title} />)}
</Box>
}
}

@ -1 +1 @@
type TypePages = "count" | "day" | "dop" | "hide" | "create" type TypePages = "count" | "day" | "dop" | "hide" | "create" | "premium" | "analytics" | "custom"

@ -0,0 +1,46 @@
import { cartApi } from "@api/cart";
import { cancelCC } from "@/stores/cc";
export const inCart = () => {
let saveCart = JSON.parse(localStorage.getItem("saveCart") || "[]");
if (Array.isArray(saveCart)) {
saveCart.forEach(async (id: string) => {
const [_, addError] = await cartApi.add(id);
if (addError) {
console.error(addError);
} else {
let index = saveCart.indexOf("green");
if (index !== -1) {
saveCart.splice(index, 1);
}
localStorage.setItem("saveCart", JSON.stringify(saveCart));
}
});
} else {
localStorage.setItem("saveCart", "[]");
}
};
export const outCart = (cart: string[]) => {
//Сделаем муторно и подольше, зато при прерывании сессии данные потеряются минимально
if (cart.length > 0) {
cart.forEach(async (id: string) => {
const [_, deleteError] = await cartApi.delete(id);
if (deleteError) {
console.error(deleteError);
cancelCC()//мы хотели открыть модалку после покупки тарифа на создание квиза, но не вышло и модалку не откроем
return;
}
let saveCart = JSON.parse(localStorage.getItem("saveCart") || "[]") || [];
if (!Array.isArray(saveCart)) saveCart = []
saveCart = saveCart.push(id);
localStorage.setItem("saveCart", JSON.stringify(saveCart));
});
}
};

@ -6,7 +6,7 @@ import MoreHorizIcon from "@mui/icons-material/MoreHoriz";
import { Box, Button, IconButton, Popover, Typography, useMediaQuery, useTheme } from "@mui/material"; import { Box, Button, IconButton, Popover, Typography, useMediaQuery, useTheme } from "@mui/material";
import { deleteQuiz, setEditQuizId } from "@root/quizes/actions"; import { deleteQuiz, setEditQuizId } from "@root/quizes/actions";
import { Link, useNavigate } from "react-router-dom"; import { Link, useNavigate } from "react-router-dom";
import { inCart } from "../../pages/Tariffs/Tariffs"; import { inCart } from "../../pages/Tariffs/utils";
import { makeRequest } from "@api/makeRequest"; import { makeRequest } from "@api/makeRequest";
import { enqueueSnackbar } from "notistack"; import { enqueueSnackbar } from "notistack";
import { useDomainDefine } from "@utils/hooks/useDomainDefine"; import { useDomainDefine } from "@utils/hooks/useDomainDefine";

@ -1,9 +1,6 @@
import { import {
Box, Box,
FormControl,
IconButton, IconButton,
InputAdornment,
InputBase,
SxProps, SxProps,
Theme, Theme,
Typography, Typography,
@ -17,23 +14,13 @@ import {
useTicketStore, useTicketStore,
} from "@root/ticket"; } from "@root/ticket";
import type { TouchEvent, WheelEvent } from "react"; import type { TouchEvent, WheelEvent } from "react";
import * as React from "react"; import { useEffect, useMemo, useRef } from "react";
import { useEffect, useMemo, useRef, useState } from "react"; import ChatMessageRenderer from "./ChatMessageRenderer";
import ChatMessage from "./ChatMessage"; import ChatInput from "./ChatInput";
import ChatVideo from "./ChatVideo";
import SendIcon from "@icons/SendIcon";
import UserCircleIcon from "./UserCircleIcon"; import UserCircleIcon from "./UserCircleIcon";
import { throttle, TicketMessage } from "@frontend/kitui"; import { throttle, TicketMessage } from "@frontend/kitui";
import ArrowLeft from "@icons/questionsPage/arrowLeft"; import ArrowLeft from "@icons/questionsPage/arrowLeft";
import { useUserStore } from "@root/user"; import { useUserStore } from "@root/user";
import AttachFileIcon from "@mui/icons-material/AttachFile";
import ChatImage from "./ChatImage";
import ChatDocument from "@ui_kit/FloatingSupportChat/ChatDocument";
import {
ACCEPT_SEND_MEDIA_TYPES_MAP,
checkAcceptableMediaType,
} from "@utils/checkAcceptableMediaType";
import { enqueueSnackbar } from "notistack";
interface Props { interface Props {
open: boolean; open: boolean;
@ -41,22 +28,20 @@ interface Props {
onclickArrow?: () => void; onclickArrow?: () => void;
sendMessage: (a: string) => Promise<boolean>; sendMessage: (a: string) => Promise<boolean>;
sendFile: (a: File | undefined) => Promise<void>; sendFile: (a: File | undefined) => Promise<void>;
greetingMessage: TicketMessage;
} }
const greetingMessage = "Здравствуйте, задайте ваш вопрос и наш оператор вам ответит в течение 10 минут";
export default function Chat({ export default function Chat({
open = false, open = false,
sx, sx,
onclickArrow, onclickArrow,
sendMessage, sendMessage,
sendFile, sendFile,
greetingMessage,
}: Props) { }: Props) {
const theme = useTheme(); const theme = useTheme();
const upMd = useMediaQuery(theme.breakpoints.up("md")); const upMd = useMediaQuery(theme.breakpoints.up("md"));
const isMobile = useMediaQuery(theme.breakpoints.down(800)); const isMobile = useMediaQuery(theme.breakpoints.down(800));
const [messageField, setMessageField] = useState<string>("");
const [disableFileButton, setDisableFileButton] = useState(false);
const user = useUserStore((state) => state.user?._id); const user = useUserStore((state) => state.user?._id);
const ticket = useTicketStore( const ticket = useTicketStore(
@ -72,31 +57,11 @@ export default function Chat({
const chatBoxRef = useRef<HTMLDivElement>(null); const chatBoxRef = useRef<HTMLDivElement>(null);
useEffect(() => { useEffect(() => {
addOrUpdateUnauthMessages([greetingMessage]);
if (open) { if (open) {
scrollToBottom(); scrollToBottom();
} }
}, [open]); }, [open]);
const sendMessageHC = async () => {
const successful = await sendMessage(messageField);
if (successful) {
setMessageField("");
}
};
const sendFileHC = async (file: File) => {
const check = checkAcceptableMediaType(file);
if (check.length > 0) {
enqueueSnackbar(check);
return;
}
setDisableFileButton(true);
await sendFile(file);
setDisableFileButton(false);
};
const fileInputRef = useRef<HTMLInputElement>(null);
const throttledScrollHandler = useMemo( const throttledScrollHandler = useMemo(
() => () =>
throttle(() => { throttle(() => {
@ -152,14 +117,6 @@ export default function Chat({
behavior, behavior,
}); });
} }
const handleTextfieldKeyPress: React.KeyboardEventHandler<
HTMLInputElement | HTMLTextAreaElement
> = (e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
sendMessageHC();
}
};
return ( return (
<> <>
@ -240,164 +197,34 @@ export default function Chat({
> >
{ticket.sessionData?.ticketId && {ticket.sessionData?.ticketId &&
messages.map((message) => { messages.map((message) => {
const isFileVideo = () => { const isSelf = useMemo(() =>
if (message.files) { (ticket.sessionData?.sessionId || user) === message.user_id,
return ACCEPT_SEND_MEDIA_TYPES_MAP.video.some( [ticket.sessionData?.sessionId, user, message.user_id]
(fileType) =>
message.files[0].toLowerCase().endsWith(fileType),
); );
}
};
const isFileImage = () => {
if (message.files) {
return ACCEPT_SEND_MEDIA_TYPES_MAP.picture.some(
(fileType) =>
message.files[0].toLowerCase().endsWith(fileType),
);
}
};
const isFileDocument = () => {
if (message.files) {
return ACCEPT_SEND_MEDIA_TYPES_MAP.document.some(
(fileType) =>
message.files[0].toLowerCase().endsWith(fileType),
);
}
};
if (message.files.length > 0 && isFileImage()) {
return ( return (
<ChatImage <ChatMessageRenderer
unAuthenticated
key={message.id} key={message.id}
file={message.files[0]} message={message}
createdAt={message.created_at} isSelf={isSelf}
isSelf={
(ticket.sessionData?.sessionId || user) ===
message.user_id
}
/>
);
}
if (message.files.length > 0 && isFileVideo()) {
return (
<ChatVideo
unAuthenticated
key={message.id}
file={message.files[0]}
createdAt={message.created_at}
isSelf={
(ticket.sessionData?.sessionId || user) ===
message.user_id
}
/>
);
}
if (message.files.length > 0 && isFileDocument()) {
return (
<ChatDocument
unAuthenticated
key={message.id}
file={message.files[0]}
createdAt={message.created_at}
isSelf={
(ticket.sessionData?.sessionId || user) ===
message.user_id
}
/>
);
}
return (
<ChatMessage
unAuthenticated
key={message.id}
text={message.message}
createdAt={message.created_at}
isSelf={
(ticket.sessionData?.sessionId || user) ===
message.user_id
}
/> />
); );
})} })}
{!ticket.sessionData?.ticketId && ( {!ticket.sessionData?.ticketId && (
<ChatMessage <ChatMessageRenderer
unAuthenticated message={greetingMessage}
text={greetingMessage.message} isSelf={useMemo(() =>
createdAt={greetingMessage.created_at} (ticket.sessionData?.sessionId || user) === greetingMessage.user_id,
isSelf={ [ticket.sessionData?.sessionId, user, greetingMessage.user_id]
(ticket.sessionData?.sessionId || user) === )}
greetingMessage.user_id
}
/> />
)} )}
</Box> </Box>
<FormControl fullWidth sx={{ borderTop: "1px solid black" }}> <ChatInput
<InputBase sendMessage={sendMessage}
value={messageField} sendFile={sendFile}
fullWidth isMessageSending={isMessageSending}
placeholder="Введите сообщение..."
id="message"
multiline
onKeyDown={handleTextfieldKeyPress}
sx={{
width: "100%",
p: 0,
}}
inputProps={{
sx: {
fontWeight: 400,
fontSize: "16px",
lineHeight: "19px",
pt: upMd ? "30px" : "28px",
pb: upMd ? "30px" : "24px",
px: "19px",
maxHeight: "calc(19px * 5)",
color: "black",
},
}}
onChange={(e) => setMessageField(e.target.value)}
endAdornment={
<InputAdornment position="end">
<IconButton
disabled={disableFileButton}
onClick={() => {
if (!disableFileButton) fileInputRef.current?.click();
}}
>
<AttachFileIcon />
</IconButton>
<input
ref={fileInputRef}
id="fileinput"
onChange={(e) => {
if (e.target.files?.[0])
sendFileHC(e.target.files?.[0]);
}}
style={{ display: "none" }}
type="file"
/> />
<IconButton
disabled={isMessageSending}
onClick={sendMessageHC}
sx={{
height: "53px",
width: "53px",
mr: "13px",
p: 0,
opacity: isMessageSending ? 0.3 : 1,
}}
>
<SendIcon
style={{
width: "100%",
height: "100%",
}}
/>
</IconButton>
</InputAdornment>
}
/>
</FormControl>
</Box> </Box>
</Box> </Box>
)} )}

@ -0,0 +1,137 @@
import { useCallback, useRef, useState } from "react";
import {
FormControl,
IconButton,
InputAdornment,
InputBase,
useMediaQuery,
useTheme,
} from "@mui/material";
import SendIcon from "@icons/SendIcon";
import AttachFileIcon from "@mui/icons-material/AttachFile";
import { checkAcceptableMediaType } from "@utils/checkAcceptableMediaType";
import { enqueueSnackbar } from "notistack";
interface ChatInputProps {
sendMessage: (message: string) => Promise<boolean>;
sendFile: (file: File | undefined) => Promise<void>;
isMessageSending: boolean;
}
const ChatInput = ({ sendMessage, sendFile, isMessageSending }: ChatInputProps) => {
const theme = useTheme();
const upMd = useMediaQuery(theme.breakpoints.up("md"));
const [messageField, setMessageField] = useState<string>("");
const [disableFileButton, setDisableFileButton] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const handleSendMessage = useCallback(async () => {
const successful = await sendMessage(messageField);
if (successful) {
setMessageField("");
}
}, [sendMessage, messageField]);
const handleSendFile = useCallback(async (file: File) => {
const check = checkAcceptableMediaType(file);
if (check.length > 0) {
enqueueSnackbar(check);
return;
}
setDisableFileButton(true);
await sendFile(file);
setDisableFileButton(false);
}, [sendFile]);
const handleFileInputChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files?.[0]) {
handleSendFile(e.target.files[0]);
}
}, [handleSendFile]);
const handleFileButtonClick = useCallback(() => {
if (!disableFileButton) {
fileInputRef.current?.click();
}
}, [disableFileButton]);
const handleTextfieldKeyPress: React.KeyboardEventHandler<
HTMLInputElement | HTMLTextAreaElement
> = useCallback((e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSendMessage();
}
}, [handleSendMessage]);
const handleInputChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
setMessageField(e.target.value);
}, []);
return (
<FormControl fullWidth sx={{ borderTop: "1px solid black" }}>
<InputBase
value={messageField}
fullWidth
placeholder="Введите сообщение..."
id="message"
multiline
onKeyDown={handleTextfieldKeyPress}
sx={{
width: "100%",
p: 0,
}}
inputProps={{
sx: {
fontWeight: 400,
fontSize: "16px",
lineHeight: "19px",
pt: upMd ? "30px" : "28px",
pb: upMd ? "30px" : "24px",
px: "19px",
maxHeight: "calc(19px * 5)",
color: "black",
},
}}
onChange={handleInputChange}
endAdornment={
<InputAdornment position="end">
<IconButton
disabled={disableFileButton}
onClick={handleFileButtonClick}
>
<AttachFileIcon />
</IconButton>
<input
ref={fileInputRef}
id="fileinput"
onChange={handleFileInputChange}
style={{ display: "none" }}
type="file"
/>
<IconButton
disabled={isMessageSending}
onClick={handleSendMessage}
sx={{
height: "53px",
width: "53px",
mr: "13px",
p: 0,
opacity: isMessageSending ? 0.3 : 1,
}}
>
<SendIcon
style={{
width: "100%",
height: "100%",
}}
/>
</IconButton>
</InputAdornment>
}
/>
</FormControl>
);
};
export default ChatInput;

@ -0,0 +1,70 @@
import { memo, useMemo } from "react";
import { TicketMessage } from "@frontend/kitui";
import ChatMessage from "./ChatMessage";
import ChatImage from "./ChatImage";
import ChatVideo from "./ChatVideo";
import ChatDocument from "./ChatDocument";
import { ACCEPT_SEND_MEDIA_TYPES_MAP } from "@utils/checkAcceptableMediaType";
interface ChatMessageRendererProps {
message: TicketMessage;
isSelf: boolean;
}
const ChatMessageRenderer = memo(({ message, isSelf }: ChatMessageRendererProps) => {
const fileType = useMemo(() => {
if (!message.files?.length) return null;
const fileName = message.files[0].toLowerCase();
if (ACCEPT_SEND_MEDIA_TYPES_MAP.video.some(fileType => fileName.endsWith(fileType))) {
return 'video';
}
if (ACCEPT_SEND_MEDIA_TYPES_MAP.picture.some(fileType => fileName.endsWith(fileType))) {
return 'image';
}
if (ACCEPT_SEND_MEDIA_TYPES_MAP.document.some(fileType => fileName.endsWith(fileType))) {
return 'document';
}
return null;
}, [message.files]);
// Если есть файлы и определён тип
if (message.files?.length > 0 && fileType) {
const commonProps = {
unAuthenticated: true,
key: message.id,
file: message.files[0],
createdAt: message.created_at,
isSelf,
};
switch (fileType) {
case 'image':
return <ChatImage {...commonProps} />;
case 'video':
return <ChatVideo {...commonProps} />;
case 'document':
return <ChatDocument {...commonProps} />;
default:
break;
}
}
// Текстовое сообщение
return (
<ChatMessage
unAuthenticated
text={message.message}
createdAt={message.created_at}
isSelf={isSelf}
/>
);
});
ChatMessageRenderer.displayName = 'ChatMessageRenderer';
export default ChatMessageRenderer;

@ -47,7 +47,6 @@ interface Props {
sendFile: (a: File | undefined) => Promise<void>; sendFile: (a: File | undefined) => Promise<void>;
modalWarningType: string | null; modalWarningType: string | null;
setModalWarningType: any; setModalWarningType: any;
greetingMessage: TicketMessage;
} }
export default function FloatingSupportChat({ export default function FloatingSupportChat({
@ -59,7 +58,6 @@ export default function FloatingSupportChat({
sendFile, sendFile,
modalWarningType, modalWarningType,
setModalWarningType, setModalWarningType,
greetingMessage,
}: Props) { }: Props) {
const [monitorType, setMonitorType] = useState<"desktop" | "mobile" | "">(""); const [monitorType, setMonitorType] = useState<"desktop" | "mobile" | "">("");
const theme = useTheme(); const theme = useTheme();
@ -108,7 +106,6 @@ export default function FloatingSupportChat({
sx={{ alignSelf: "start", width: "clamp(200px, 100%, 400px)" }} sx={{ alignSelf: "start", width: "clamp(200px, 100%, 400px)" }}
sendMessage={sendMessage} sendMessage={sendMessage}
sendFile={sendFile} sendFile={sendFile}
greetingMessage={greetingMessage}
/> />
<Dialog <Dialog
fullScreen fullScreen
@ -121,7 +118,6 @@ export default function FloatingSupportChat({
onclickArrow={handleChatClickClose} onclickArrow={handleChatClickClose}
sendMessage={sendMessage} sendMessage={sendMessage}
sendFile={sendFile} sendFile={sendFile}
greetingMessage={greetingMessage}
/> />
</Dialog> </Dialog>
<Fab <Fab

@ -72,33 +72,6 @@ export default () => {
setIsChatOpened((state) => !state); setIsChatOpened((state) => !state);
}; };
const getGreetingMessage: TicketMessage = useMemo(() => {
const workingHoursMessage =
"Здравствуйте, задайте ваш вопрос и наш оператор вам ответит в течение 10 минут";
const offHoursMessage =
"Здравствуйте, задайте ваш вопрос и наш оператор вам ответит в течение 10 минут";
const date = new Date();
const currentHourUTC = date.getUTCHours();
const MscTime = 3; // Москва UTC+3;
const moscowHour = (currentHourUTC + MscTime) % 24;
const greetingMessage =
moscowHour >= 3 && moscowHour < 10
? offHoursMessage
: workingHoursMessage;
return {
created_at: new Date().toISOString(),
files: [],
id: "111",
message: greetingMessage,
request_screenshot: "",
session_id: "greetingMessage",
shown: { me: 1 },
ticket_id: "111",
user_id: "greetingMessage",
};
}, [isChatOpened]);
useTicketsFetcher({ useTicketsFetcher({
url: `${process.env.REACT_APP_DOMAIN}/heruvym/v1.0.0/getTickets`, url: `${process.env.REACT_APP_DOMAIN}/heruvym/v1.0.0/getTickets`,
ticketsPerPage: 10, ticketsPerPage: 10,
@ -157,7 +130,6 @@ export default () => {
); );
if (isTicketClosed) { if (isTicketClosed) {
cleanAuthTicketData(); cleanAuthTicketData();
addOrUpdateUnauthMessages([getGreetingMessage]);
if (!user) { if (!user) {
cleanUnauthTicketData(); cleanUnauthTicketData();
localStorage.removeItem("unauth-ticket"); localStorage.removeItem("unauth-ticket");
@ -185,8 +157,8 @@ export default () => {
({ shown }) => shown?.me !== 1, ({ shown }) => shown?.me !== 1,
); );
newMessages.map(async ({ id }) => { newMessages.forEach(({ id, user_id }) => {
await shownMessage(id); if ((ticket.sessionData?.sessionId || user) === user_id) shownMessage(id);
}); });
} }
}, [isChatOpened, ticket.messages]); }, [isChatOpened, ticket.messages]);
@ -248,7 +220,6 @@ export default () => {
sendFile={sendFile} sendFile={sendFile}
modalWarningType={modalWarningType} modalWarningType={modalWarningType}
setModalWarningType={setModalWarningType} setModalWarningType={setModalWarningType}
greetingMessage={getGreetingMessage}
/> />
); );
}; };

@ -1,274 +0,0 @@
import { useSSETab } from "@/utils/hooks/useSSETab";
import { parseAxiosError } from "@/utils/parse-error";
import { TicketMessage, createTicket, useSSESubscription, useTicketMessages, useTicketsFetcher, sendFile as sf, sendTicketMessage, shownMessage } from "@frontend/kitui";
import {
addOrUpdateUnauthMessages,
cleanAuthTicketData,
cleanUnauthTicketData,
setIsMessageSending,
setTicketData,
setUnauthIsPreventAutoscroll,
setUnauthTicketMessageFetchState,
useTicketStore,
} from "@root/ticket";
import { enqueueSnackbar } from "notistack";
import { useCallback, useEffect, useMemo, useState } from "react";
interface Props {
userId?: string;
}
type ModalWarningType =
| "errorType"
| "errorSize"
| "picture"
| "video"
| "audio"
| "document"
| null;
const MAX_FILE_SIZE = 419430400;
const ACCEPT_SEND_FILE_TYPES_MAP = [
".jpeg",
".jpg",
".png",
".mp4",
".doc",
".docx",
".pdf",
".txt",
".xlsx",
".csv",
] as const;
export default ({ userId }: Props) => {
const ticket = useTicketStore((state) => state[userId ? "authData" : "unauthData"]);
const { isActiveSSETab, updateSSEValue } = useSSETab<TicketMessage[]>(
"ticket",
addOrUpdateUnauthMessages,
);
const [modalWarningType, setModalWarningType] =
useState<ModalWarningType>(null);
const [isChatOpened, setIsChatOpened] = useState<boolean>(false);
const [sseEnabled, setSseEnabled] = useState(true);
const handleChatClickOpen = () => {
setIsChatOpened(true);
};
const handleChatClickClose = () => {
setIsChatOpened(false);
};
const handleChatClickSwitch = () => {
setIsChatOpened((state) => !state);
};
const getGreetingMessage: TicketMessage = useMemo(() => {
const workingHoursMessage =
"Здравствуйте, задайте ваш вопрос и наш оператор вам ответит в течение 10 минут";
const offHoursMessage =
"Здравствуйте, задайте ваш вопрос и наш оператор вам ответит в течение 10 минут";
const date = new Date();
const currentHourUTC = date.getUTCHours();
const MscTime = 3; // Москва UTC+3;
const moscowHour = (currentHourUTC + MscTime) % 24;
const greetingMessage =
moscowHour >= 3 && moscowHour < 10
? offHoursMessage
: workingHoursMessage;
return {
created_at: new Date().toISOString(),
files: [],
id: "111",
message: greetingMessage,
request_screenshot: "",
session_id: "greetingMessage",
shown: { me: 1 },
ticket_id: "111",
user_id: "greetingMessage",
};
}, [isChatOpened]);
useTicketsFetcher({
url: `${process.env.REACT_APP_DOMAIN}/heruvym/v1.0.0/getTickets`,
ticketsPerPage: 10,
ticketApiPage: 0,
onSuccess: (result) => {
if (result.data?.length) {
const currentTicket = result.data.find(
({ origin }) => !origin.includes("/support"),
);
if (!currentTicket) {
return;
}
setTicketData({
ticketId: currentTicket.id,
sessionId: currentTicket.sess,
});
}
},
onError: (error: Error) => {
const message = parseAxiosError(error);
if (message) enqueueSnackbar(message);
},
onFetchStateChange: () => { },
enabled: Boolean(userId),
});
useTicketMessages({
url: `${process.env.REACT_APP_DOMAIN}/heruvym/v1.0.0/getMessages`,
isUnauth: true,
ticketId: ticket.sessionData?.ticketId,
messagesPerPage: ticket.messagesPerPage,
messageApiPage: ticket.apiPage,
onSuccess: useCallback((messages) => {
addOrUpdateUnauthMessages(messages);
}, []),
onError: useCallback((error: Error) => {
if (error.name === "CanceledError") {
return;
}
const [message] = parseAxiosError(error);
if (message) enqueueSnackbar(message);
}, []),
onFetchStateChange: setUnauthTicketMessageFetchState,
});
useSSESubscription<TicketMessage>({
enabled:
sseEnabled && isActiveSSETab && Boolean(ticket.sessionData?.sessionId),
url: `${process.env.REACT_APP_DOMAIN}/heruvym/v1.0.0/ticket?ticket=${ticket.sessionData?.ticketId}&s=${ticket.sessionData?.sessionId}`,
onNewData: (ticketMessages) => {
const isTicketClosed = ticketMessages.some(
(message) => message.session_id === "close",
);
if (isTicketClosed) {
cleanAuthTicketData();
addOrUpdateUnauthMessages([getGreetingMessage]);
if (!userId) {
cleanUnauthTicketData();
localStorage.removeItem("unauth-ticket");
}
return;
}
updateSSEValue(ticketMessages);
addOrUpdateUnauthMessages(ticketMessages);
},
onDisconnect: useCallback(() => {
setUnauthIsPreventAutoscroll(false);
setSseEnabled(false);
}, []),
marker: "ticket",
});
useEffect(() => {
cleanAuthTicketData();
setSseEnabled(true);
}, [userId]);
useEffect(() => {
if (isChatOpened) {
const newMessages = ticket.messages.filter(
({ shown }) => shown?.me !== 1,
);
newMessages.map(async ({ id }) => {
await shownMessage(id);
});
}
}, [isChatOpened, ticket.messages]);
const sendMessage = async (messageField: string) => {
if (!messageField || ticket.isMessageSending) return false;
setSseEnabled(true);
let successful = false;
setIsMessageSending(true);
if (!ticket.sessionData?.ticketId) {
const [data, createError] = await createTicket({
message: messageField,
useToken: Boolean(userId),
systemError: false
});
if (createError || !data) {
successful = false;
enqueueSnackbar(`Не удалось создать чат ${(createError)}`);
} else {
successful = true;
setTicketData({ ticketId: data.Ticket, sessionId: data.sess });
}
setIsMessageSending(false);
} else {
const [_, sendTicketMessageError] = await sendTicketMessage({
ticketId: ticket.sessionData?.ticketId,
message: messageField,
systemError: false
});
successful = true;
if (sendTicketMessageError) {
successful = false;
enqueueSnackbar(`Ошибка отправки сообщения ${parseAxiosError(sendTicketMessageError)}`);
}
setIsMessageSending(false);
}
return successful;
};
const sendFile = async (file: File) => {
if (file === undefined) return true;
let ticketId = ticket.sessionData?.ticketId;
if (!ticket.sessionData?.ticketId) {
const [data, createError] = await createTicket({
message: "",
useToken: Boolean(userId),
systemError: false
});
ticketId = data?.Ticket;
if (createError || !data) {
enqueueSnackbar(`Не удалось создать диалог ${parseAxiosError(createError)}`);
} else {
setTicketData({ ticketId: data.Ticket, sessionId: data.sess });
}
setIsMessageSending(false);
}
if (ticketId !== undefined) {
if (file.size > MAX_FILE_SIZE) return setModalWarningType("errorSize");
const [_, sendFileError] = await sf({
ticketId,
file
});
if (sendFileError) {
enqueueSnackbar(sendFileError);
}
return true;
}
};
return {
isChatOpened,
handleChatClickOpen,
handleChatClickClose,
handleChatClickSwitch,
sendMessage,
sendFile,
modalWarningType,
setModalWarningType,
getGreetingMessage
};
};

@ -4,7 +4,7 @@ import { useSSETab } from "./useSSETab";
import { cancelPayCartProcess } from "@/stores/notEnoughMoneyAmount"; import { cancelPayCartProcess } from "@/stores/notEnoughMoneyAmount";
import { setCash } from "@/stores/cash"; import { setCash } from "@/stores/cash";
import { currencyFormatter } from "@/pages/Tariffs/tariffsUtils/currencyFormatter"; import { currencyFormatter } from "@/pages/Tariffs/tariffsUtils/currencyFormatter";
import { inCart } from "@/pages/Tariffs/Tariffs"; import { inCart } from "@/pages/Tariffs/utils";
type Ping = [{ event: "ping" }] type Ping = [{ event: "ping" }]