diff --git a/src/App.tsx b/src/App.tsx index c71f16e7..036f0f64 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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 ( <> + {location.state?.backgroundLocation && ( } /> diff --git a/src/api/ticket.ts b/src/api/ticket.ts new file mode 100644 index 00000000..111755fb --- /dev/null +++ b/src/api/ticket.ts @@ -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}`]; + } +} diff --git a/src/assets/icons/SendIcon.tsx b/src/assets/icons/SendIcon.tsx index 9a0f999b..ff96b1d5 100755 --- a/src/assets/icons/SendIcon.tsx +++ b/src/assets/icons/SendIcon.tsx @@ -1,4 +1,10 @@ -export default function SendIcon() { +import { CSSProperties } from "react"; + +interface Props { + style?: CSSProperties; +} + +export default function SendIcon({ style }: Props) { return ( ()( + 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; +} diff --git a/src/ui_kit/FloatingSupportChat/Chat.tsx b/src/ui_kit/FloatingSupportChat/Chat.tsx new file mode 100644 index 00000000..75e992a6 --- /dev/null +++ b/src/ui_kit/FloatingSupportChat/Chat.tsx @@ -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; +} + +export default function Chat({ sx }: Props) { + const theme = useTheme(); + const upMd = useMediaQuery(theme.breakpoints.up("md")); + const [messageField, setMessageField] = useState(""); + 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(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({ + 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 ( + + + + + Мария + + онлайн-консультант + + + + + + {sessionData && + messages.map((message) => ( + + ))} + + + setMessageField(e.target.value)} + endAdornment={ + + + + + + } + /> + + + + ); +} diff --git a/src/ui_kit/FloatingSupportChat/ChatMessage.tsx b/src/ui_kit/FloatingSupportChat/ChatMessage.tsx new file mode 100644 index 00000000..3f6c15e0 --- /dev/null +++ b/src/ui_kit/FloatingSupportChat/ChatMessage.tsx @@ -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 ( + + + {time} + + + + + + + + {text} + + + + ); +} diff --git a/src/ui_kit/FloatingSupportChat/CircleDoubleDownIcon.tsx b/src/ui_kit/FloatingSupportChat/CircleDoubleDownIcon.tsx new file mode 100644 index 00000000..d7797ea9 --- /dev/null +++ b/src/ui_kit/FloatingSupportChat/CircleDoubleDownIcon.tsx @@ -0,0 +1,50 @@ +import { Box } from "@mui/material"; + +interface Props { + isUp?: boolean; +} +export default function CircleDoubleDown({ isUp = false }: Props) { + return ( + + + + + + + + ); +} diff --git a/src/ui_kit/FloatingSupportChat/FloatingSupportChat.tsx b/src/ui_kit/FloatingSupportChat/FloatingSupportChat.tsx new file mode 100644 index 00000000..c984ec00 --- /dev/null +++ b/src/ui_kit/FloatingSupportChat/FloatingSupportChat.tsx @@ -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(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 ( + + {isChatOpened && ( + + )} + setIsChatOpened((prev) => !prev)} + > + {!isChatOpened && ( + + )} + + + {!isChatOpened && ( + Задайте нам вопрос + )} + + + ); +} diff --git a/src/ui_kit/FloatingSupportChat/UserCircleIcon.tsx b/src/ui_kit/FloatingSupportChat/UserCircleIcon.tsx new file mode 100644 index 00000000..a4199db4 --- /dev/null +++ b/src/ui_kit/FloatingSupportChat/UserCircleIcon.tsx @@ -0,0 +1,46 @@ +import { Box } from "@mui/material"; + +export default function UserCircleIcon() { + return ( + + + + + + + + ); +} diff --git a/src/utils/date.ts b/src/utils/date.ts new file mode 100644 index 00000000..97dea97f --- /dev/null +++ b/src/utils/date.ts @@ -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}`; +} diff --git a/src/utils/declension.ts b/src/utils/declension.ts new file mode 100644 index 00000000..169512bd --- /dev/null +++ b/src/utils/declension.ts @@ -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 "ед."; + } +}