feat: support chat
This commit is contained in:
parent
cc743f5915
commit
dfb6fad6ae
@ -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
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
|
||||
|
||||
91
src/stores/unauthTicket.ts
Normal file
91
src/stores/unauthTicket.ts
Normal file
@ -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;
|
||||
}
|
||||
320
src/ui_kit/FloatingSupportChat/Chat.tsx
Normal file
320
src/ui_kit/FloatingSupportChat/Chat.tsx
Normal file
@ -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>
|
||||
);
|
||||
}
|
||||
109
src/ui_kit/FloatingSupportChat/ChatMessage.tsx
Normal file
109
src/ui_kit/FloatingSupportChat/ChatMessage.tsx
Normal file
@ -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>
|
||||
);
|
||||
}
|
||||
50
src/ui_kit/FloatingSupportChat/CircleDoubleDownIcon.tsx
Normal file
50
src/ui_kit/FloatingSupportChat/CircleDoubleDownIcon.tsx
Normal file
@ -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>
|
||||
);
|
||||
}
|
||||
97
src/ui_kit/FloatingSupportChat/FloatingSupportChat.tsx
Normal file
97
src/ui_kit/FloatingSupportChat/FloatingSupportChat.tsx
Normal file
@ -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>
|
||||
);
|
||||
}
|
||||
46
src/ui_kit/FloatingSupportChat/UserCircleIcon.tsx
Normal file
46
src/ui_kit/FloatingSupportChat/UserCircleIcon.tsx
Normal file
@ -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
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
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 "ед.";
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user