feat: support chat

This commit is contained in:
IlyaDoronin 2024-02-06 17:39:02 +03:00
parent cc743f5915
commit dfb6fad6ae
11 changed files with 829 additions and 1 deletions

@ -42,6 +42,7 @@ import {
} from "@root/user";
import { enqueueSnackbar } from "notistack";
import PrivateRoute from "@ui_kit/PrivateRoute";
import FloatingSupportChat from "@ui_kit/FloatingSupportChat/FloatingSupportChat";
import { Restore } from "./pages/auth/Restore";
@ -49,6 +50,7 @@ import { isAxiosError } from "axios";
import { useEffect, useLayoutEffect, useRef } from "react";
import RecoverPassword from "./pages/auth/RecoverPassword";
import OutdatedLink from "./pages/auth/OutdatedLink";
export function useUserAccountFetcher({
onError,
onNewUserAccount,
@ -181,6 +183,7 @@ export default function App() {
return (
<>
<ContactFormModal />
<FloatingSupportChat />
{location.state?.backgroundLocation && (
<Routes>
<Route path="/signin" element={<SigninDialog />} />

46
src/api/ticket.ts Normal file

@ -0,0 +1,46 @@
import { makeRequest } from "@frontend/kitui";
import { parseAxiosError } from "../utils/parse-error";
import { SendTicketMessageRequest } from "@frontend/kitui";
const apiUrl = process.env.REACT_APP_DOMAIN + "/heruvym";
export async function sendTicketMessage(
ticketId: string,
message: string,
): Promise<[null, string?]> {
try {
const sendTicketMessageResponse = await makeRequest<
SendTicketMessageRequest,
null
>({
url: `${apiUrl}/send`,
method: "POST",
useToken: true,
body: { ticket: ticketId, message: message, lang: "ru", files: [] },
});
return [sendTicketMessageResponse];
} catch (nativeError) {
const [error] = parseAxiosError(nativeError);
return [null, `Не удалось отправить сообщение. ${error}`];
}
}
export async function shownMessage(id: string): Promise<[null, string?]> {
try {
const shownMessageResponse = await makeRequest<{ id: string }, null>({
url: apiUrl + "/shown",
method: "POST",
useToken: true,
body: { id },
});
return [shownMessageResponse];
} catch (nativeError) {
const [error] = parseAxiosError(nativeError);
return [null, `Не удалось прочесть сообщение. ${error}`];
}
}

@ -1,4 +1,10 @@
export default function SendIcon() {
import { CSSProperties } from "react";
interface Props {
style?: CSSProperties;
}
export default function SendIcon({ style }: Props) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
@ -6,6 +12,7 @@ export default function SendIcon() {
height="45"
viewBox="0 0 45 45"
fill="none"
style={style}
>
<circle cx="22.5" cy="22.5" r="22.5" fill="#944FEE" />
<path

@ -0,0 +1,91 @@
import { FetchState, TicketMessage } from "@frontend/kitui";
import { create } from "zustand";
import { createJSONStorage, devtools, persist } from "zustand/middleware";
interface UnauthTicketStore {
sessionData: {
ticketId: string;
sessionId: string;
} | null;
isMessageSending: boolean;
messages: TicketMessage[];
apiPage: number;
messagesPerPage: number;
lastMessageId: string | undefined;
isPreventAutoscroll: boolean;
unauthTicketMessageFetchState: FetchState;
}
export const useUnauthTicketStore = create<UnauthTicketStore>()(
persist(
devtools(
(set, get) => ({
sessionData: null,
isMessageSending: false,
messages: [],
apiPage: 0,
messagesPerPage: 10,
lastMessageId: undefined,
isPreventAutoscroll: false,
unauthTicketMessageFetchState: "idle",
}),
{
name: "Unauth tickets",
},
),
{
version: 0,
name: "unauth-ticket",
storage: createJSONStorage(() => localStorage),
partialize: (state) => ({
sessionData: state.sessionData,
}),
},
),
);
export const setUnauthSessionData = (
sessionData: UnauthTicketStore["sessionData"],
) => useUnauthTicketStore.setState({ sessionData });
export const setIsMessageSending = (
isMessageSending: UnauthTicketStore["isMessageSending"],
) => useUnauthTicketStore.setState({ isMessageSending });
export const addOrUpdateUnauthMessages = (
receivedMessages: TicketMessage[],
) => {
const state = useUnauthTicketStore.getState();
const messageIdToMessageMap: { [messageId: string]: TicketMessage } = {};
[...state.messages, ...receivedMessages].forEach(
(message) => (messageIdToMessageMap[message.id] = message),
);
const sortedMessages = Object.values(messageIdToMessageMap).sort(
sortMessagesByTime,
);
useUnauthTicketStore.setState({
messages: sortedMessages,
lastMessageId: sortedMessages.at(-1)?.id,
});
};
export const incrementUnauthMessageApiPage = () => {
const state = useUnauthTicketStore.getState();
useUnauthTicketStore.setState({ apiPage: state.apiPage + 1 });
};
export const setUnauthIsPreventAutoscroll = (isPreventAutoscroll: boolean) =>
useUnauthTicketStore.setState({ isPreventAutoscroll });
export const setUnauthTicketMessageFetchState = (
unauthTicketMessageFetchState: FetchState,
) => useUnauthTicketStore.setState({ unauthTicketMessageFetchState });
function sortMessagesByTime(ticket1: TicketMessage, ticket2: TicketMessage) {
const date1 = new Date(ticket1.created_at).getTime();
const date2 = new Date(ticket2.created_at).getTime();
return date1 - date2;
}

@ -0,0 +1,320 @@
import {
Box,
FormControl,
IconButton,
InputAdornment,
InputBase,
SxProps,
Theme,
Typography,
useMediaQuery,
useTheme,
} from "@mui/material";
import { TicketMessage } from "@frontend/kitui";
import {
addOrUpdateUnauthMessages,
useUnauthTicketStore,
incrementUnauthMessageApiPage,
setUnauthIsPreventAutoscroll,
setUnauthSessionData,
setIsMessageSending,
setUnauthTicketMessageFetchState,
} from "@root/unauthTicket";
import { enqueueSnackbar } from "notistack";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import ChatMessage from "./ChatMessage";
import SendIcon from "@icons/SendIcon";
import UserCircleIcon from "./UserCircleIcon";
import { throttle } from "@frontend/kitui";
import {
useTicketMessages,
getMessageFromFetchError,
useSSESubscription,
useEventListener,
createTicket,
} from "@frontend/kitui";
import { sendTicketMessage } from "../../api/ticket";
interface Props {
sx?: SxProps<Theme>;
}
export default function Chat({ sx }: Props) {
const theme = useTheme();
const upMd = useMediaQuery(theme.breakpoints.up("md"));
const [messageField, setMessageField] = useState<string>("");
const sessionData = useUnauthTicketStore((state) => state.sessionData);
const messages = useUnauthTicketStore((state) => state.messages);
const messageApiPage = useUnauthTicketStore((state) => state.apiPage);
const messagesPerPage = useUnauthTicketStore(
(state) => state.messagesPerPage,
);
const isMessageSending = useUnauthTicketStore(
(state) => state.isMessageSending,
);
const isPreventAutoscroll = useUnauthTicketStore(
(state) => state.isPreventAutoscroll,
);
const lastMessageId = useUnauthTicketStore((state) => state.lastMessageId);
const fetchState = useUnauthTicketStore(
(state) => state.unauthTicketMessageFetchState,
);
const chatBoxRef = useRef<HTMLDivElement>(null);
useTicketMessages({
url: process.env.REACT_APP_DOMAIN + "/heruvym/getMessages",
isUnauth: true,
ticketId: sessionData?.ticketId,
messagesPerPage,
messageApiPage,
onSuccess: useCallback((messages) => {
if (chatBoxRef.current && chatBoxRef.current.scrollTop < 1)
chatBoxRef.current.scrollTop = 1;
addOrUpdateUnauthMessages(messages);
}, []),
onError: useCallback((error: Error) => {
const message = getMessageFromFetchError(error);
if (message) enqueueSnackbar(message);
}, []),
onFetchStateChange: setUnauthTicketMessageFetchState,
});
useSSESubscription<TicketMessage>({
enabled: Boolean(sessionData),
url:
process.env.REACT_APP_DOMAIN +
`/heruvym/ticket?ticket=${sessionData?.ticketId}&s=${sessionData?.sessionId}`,
onNewData: addOrUpdateUnauthMessages,
onDisconnect: useCallback(() => {
setUnauthIsPreventAutoscroll(false);
}, []),
marker: "ticket",
});
const throttledScrollHandler = useMemo(
() =>
throttle(() => {
const chatBox = chatBoxRef.current;
if (!chatBox) return;
const scrollBottom =
chatBox.scrollHeight - chatBox.scrollTop - chatBox.clientHeight;
const isPreventAutoscroll = scrollBottom > chatBox.clientHeight;
setUnauthIsPreventAutoscroll(isPreventAutoscroll);
if (fetchState !== "idle") return;
if (chatBox.scrollTop < chatBox.clientHeight) {
incrementUnauthMessageApiPage();
}
}, 200),
[fetchState],
);
useEventListener("scroll", throttledScrollHandler, chatBoxRef);
useEffect(
function scrollOnNewMessage() {
if (!chatBoxRef.current) return;
if (!isPreventAutoscroll) {
setTimeout(() => {
scrollToBottom();
}, 50);
}
},
[lastMessageId],
);
async function handleSendMessage() {
if (!messageField || isMessageSending) return;
if (!sessionData) {
setIsMessageSending(true);
createTicket({
url: process.env.REACT_APP_DOMAIN + "/heruvym/create",
body: {
Title: "Unauth title",
Message: messageField,
},
useToken: false,
})
.then((response) => {
setUnauthSessionData({
ticketId: response.Ticket,
sessionId: response.sess,
});
})
.catch((error) => {
const errorMessage = getMessageFromFetchError(error);
if (errorMessage) enqueueSnackbar(errorMessage);
})
.finally(() => {
setMessageField("");
setIsMessageSending(false);
});
} else {
setIsMessageSending(true);
const [_, sendTicketMessageError] = await sendTicketMessage(
sessionData.ticketId,
messageField,
);
if (sendTicketMessageError) {
enqueueSnackbar(sendTicketMessageError);
}
setMessageField("");
setIsMessageSending(false);
}
}
function scrollToBottom(behavior?: ScrollBehavior) {
if (!chatBoxRef.current) return;
const chatBox = chatBoxRef.current;
chatBox.scroll({
left: 0,
top: chatBox.scrollHeight,
behavior,
});
}
const handleTextfieldKeyPress: React.KeyboardEventHandler<
HTMLInputElement | HTMLTextAreaElement
> = (e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSendMessage();
}
};
return (
<Box
sx={{
display: "flex",
flexDirection: "column",
height: "clamp(250px, calc(100vh - 90px), 600px)",
backgroundColor: "#944FEE",
borderRadius: "8px",
...sx,
}}
>
<Box
sx={{
display: "flex",
gap: "9px",
pl: "22px",
pt: "12px",
pb: "20px",
filter: "drop-shadow(0px 3px 12px rgba(37, 39, 52, 0.3))",
}}
>
<UserCircleIcon />
<Box
sx={{
mt: "5px",
display: "flex",
flexDirection: "column",
gap: "3px",
}}
>
<Typography>Мария</Typography>
<Typography
sx={{
fontSize: "16px",
lineHeight: "19px",
}}
>
онлайн-консультант
</Typography>
</Box>
</Box>
<Box
sx={{
flexGrow: 1,
backgroundColor: "white",
borderRadius: "8px",
display: "flex",
flexDirection: "column",
}}
>
<Box
ref={chatBoxRef}
sx={{
display: "flex",
width: "100%",
flexBasis: 0,
flexDirection: "column",
gap: upMd ? "20px" : "16px",
px: upMd ? "20px" : "5px",
py: upMd ? "20px" : "13px",
overflowY: "auto",
flexGrow: 1,
}}
>
{sessionData &&
messages.map((message) => (
<ChatMessage
unAuthenticated
key={message.id}
text={message.message}
createdAt={message.created_at}
isSelf={sessionData.sessionId === message.user_id}
/>
))}
</Box>
<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={(e) => setMessageField(e.target.value)}
endAdornment={
<InputAdornment position="end">
<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>
</Box>
</Box>
);
}

@ -0,0 +1,109 @@
import { Box, Typography, useMediaQuery, useTheme } from "@mui/material";
import { isDateToday } from "../../utils/date";
interface Props {
unAuthenticated?: boolean;
isSelf: boolean;
text: string;
createdAt: string;
}
export default function ChatMessage({
unAuthenticated = false,
isSelf,
text,
createdAt,
}: Props) {
const theme = useTheme();
const upMd = useMediaQuery(theme.breakpoints.up("md"));
const messageBackgroundColor = isSelf
? "white"
: unAuthenticated
? "#EFF0F5"
: theme.palette.grey1.main;
const date = new Date(createdAt);
const time = date.toLocaleString([], {
hour: "2-digit",
minute: "2-digit",
...(!isDateToday(date) && {
year: "2-digit",
month: "2-digit",
day: "2-digit",
}),
});
return (
<Box
sx={{
display: "flex",
alignSelf: isSelf ? "end" : "start",
gap: "9px",
pl: isSelf ? undefined : "8px",
pr: isSelf ? "8px" : undefined,
}}
>
<Typography
sx={{
alignSelf: "end",
fontWeight: 400,
fontSize: "14px",
lineHeight: "17px",
order: isSelf ? 1 : 2,
color: theme.palette.grey1.main,
mb: "-4px",
whiteSpace: "nowrap",
}}
>
{time}
</Typography>
<Box
sx={{
backgroundColor: messageBackgroundColor,
border: unAuthenticated
? "1px solid #E3E3E3"
: `1px solid ${theme.palette.grey1.main}`,
order: isSelf ? 2 : 1,
p: upMd ? "18px" : "12px",
borderRadius: "8px",
maxWidth: "464px",
color: isSelf || unAuthenticated ? theme.palette.grey1.dark : "white",
position: "relative",
}}
>
<svg
style={{
position: "absolute",
top: "-1px",
right: isSelf ? "-8px" : undefined,
left: isSelf ? undefined : "-8px",
transform: isSelf ? undefined : "scale(-1, 1)",
}}
xmlns="http://www.w3.org/2000/svg"
width="16"
height="8"
viewBox="0 0 16 8"
fill="none"
>
<path
d="M0.5 0.5L15.5 0.500007
C10 0.500006 7.5 8 7.5 7.5H7.5H0.5V0.5Z"
fill={messageBackgroundColor}
stroke={unAuthenticated ? "#E3E3E3" : theme.palette.grey1.main}
/>
<rect y="1" width="8" height="8" fill={messageBackgroundColor} />
</svg>
<Typography
sx={{
fontWeight: 400,
fontSize: "16px",
lineHeight: "19px",
}}
>
{text}
</Typography>
</Box>
</Box>
);
}

@ -0,0 +1,50 @@
import { Box } from "@mui/material";
interface Props {
isUp?: boolean;
}
export default function CircleDoubleDown({ isUp = false }: Props) {
return (
<Box
sx={{
width: "32px",
height: "32px",
display: "flex",
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
transform: isUp ? "scale(1, -1)" : undefined,
}}
>
<svg
width="33"
height="32"
viewBox="0 0 33 32"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M16.9004 4C10.273 4 4.90039 9.37258 4.90039 16C4.90039 22.6274 10.273 28 16.9004 28C23.5278 28 28.9004 22.6274 28.9004 16C28.9004 9.37258 23.5278 4 16.9004 4Z"
stroke="#252734"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M12.9004 21L16.9004 17L20.9004 21"
stroke="#252734"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M12.9004 14L16.9004 10L20.9004 14"
stroke="#252734"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</Box>
);
}

@ -0,0 +1,97 @@
import { Box, Fab, Typography } from "@mui/material";
import { useState } from "react";
import CircleDoubleDown from "./CircleDoubleDownIcon";
import Chat from "./Chat";
export default function FloatingSupportChat() {
const [isChatOpened, setIsChatOpened] = useState<boolean>(false);
const animation = {
"@keyframes runningStripe": {
"0%": {
left: "10%",
backgroundColor: "transparent",
},
"10%": {
backgroundColor: "#ffffff",
},
"50%": {
backgroundColor: "#ffffff",
transform: "translate(400px, 0)",
},
"80%": {
backgroundColor: "#ffffff",
},
"100%": {
backgroundColor: "transparent",
boxShadow: "none",
left: "100%",
},
},
};
return (
<Box
sx={{
position: "fixed",
right: "20px",
bottom: "10px",
display: "flex",
flexDirection: "column",
gap: "8px",
width: "clamp(200px, 100% - 40px, 454px)",
zIndex: 10,
}}
>
{isChatOpened && (
<Chat
sx={{
alignSelf: "start",
width: "clamp(200px, 100%, 400px)",
}}
/>
)}
<Fab
disableRipple
sx={{
position: "relative",
backgroundColor: "rgba(255, 255, 255, 0.7)",
pl: "11px",
pr: !isChatOpened ? "15px" : "11px",
gap: "11px",
height: "54px",
borderRadius: "27px",
alignSelf: "end",
overflow: "hidden",
"&:hover": {
background: "rgba(255, 255, 255, 0.7)",
},
}}
variant={"extended"}
onClick={() => setIsChatOpened((prev) => !prev)}
>
{!isChatOpened && (
<Box
sx={{
position: "absolute",
bgcolor: "#FFFFFF",
height: "100px",
width: "25px",
animation: "runningStripe linear 3s infinite",
transform:
" skew(-10deg) rotate(70deg) skewX(20deg) skewY(10deg)",
boxShadow: "0px 3px 12px rgba(126, 42, 234, 0.1)",
opacity: "0.4",
...animation,
}}
/>
)}
<CircleDoubleDown isUp={isChatOpened} />
{!isChatOpened && (
<Typography sx={{ zIndex: "10000" }}>Задайте нам вопрос</Typography>
)}
</Fab>
</Box>
);
}

@ -0,0 +1,46 @@
import { Box } from "@mui/material";
export default function UserCircleIcon() {
return (
<Box
sx={{
width: "32px",
height: "32px",
display: "flex",
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
}}
>
<svg
width="32"
height="33"
viewBox="0 0 32 33"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M16 28.5C22.6274 28.5 28 23.1274 28 16.5C28 9.87258 22.6274 4.5 16 4.5C9.37258 4.5 4 9.87258 4 16.5C4 23.1274 9.37258 28.5 16 28.5Z"
stroke="white"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M16 20.5C18.7614 20.5 21 18.2614 21 15.5C21 12.7386 18.7614 10.5 16 10.5C13.2386 10.5 11 12.7386 11 15.5C11 18.2614 13.2386 20.5 16 20.5Z"
stroke="white"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M7.97461 25.425C8.727 23.943 9.87506 22.6983 11.2915 21.8289C12.708 20.9595 14.3376 20.4992 15.9996 20.4992C17.6616 20.4992 19.2912 20.9595 20.7077 21.8289C22.1242 22.6983 23.2722 23.943 24.0246 25.425"
stroke="white"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</Box>
);
}

26
src/utils/date.ts Normal file

@ -0,0 +1,26 @@
import { getDeclension } from "./declension";
export function isDateToday(date: Date): boolean {
const today = new Date();
today.setHours(0, 0, 0, 0);
return date.getTime() > today.getTime();
}
const avgDaysInMonth = 30.43692;
const avgDaysInYear = 365.242199;
export function formatDateWithDeclention(numberOfDays: number) {
if (numberOfDays === 0) return "0 дней";
const years = Math.floor(numberOfDays / avgDaysInYear);
const months = Math.floor((numberOfDays % avgDaysInYear) / avgDaysInMonth);
const days = Math.floor((numberOfDays % avgDaysInYear) % avgDaysInMonth);
const yearsDisplay =
years > 0 ? `${years} ${getDeclension(years, "год")}` : "";
const monthsDisplay =
months > 0 ? `${months} ${getDeclension(months, "месяц")}` : "";
const daysDisplay = days > 0 ? `${days} ${getDeclension(days, "день")}` : "";
return `${yearsDisplay} ${monthsDisplay} ${daysDisplay}`;
}

33
src/utils/declension.ts Normal file

@ -0,0 +1,33 @@
import { PrivilegeValueType } from "@frontend/kitui";
function declension(
number: number,
declensions: string[],
cases = [2, 0, 1, 1, 1, 2],
) {
return declensions[
number % 100 > 4 && number % 100 < 20
? 2
: cases[number % 10 < 5 ? number % 10 : 5]
];
}
export function getDeclension(
number: number,
word: PrivilegeValueType | "месяц" | "год" | string,
): string {
switch (word) {
case "шаблон":
return declension(number, ["шаблон", "шаблона", "шаблонов"]);
case "день":
return declension(number, ["день", "дня", "дней"]);
case "месяц":
return declension(number, ["месяц", "месяца", "месяцев"]);
case "год":
return declension(number, ["год", "года", "лет"]);
case "МБ":
return "МБ";
default:
return "ед.";
}
}