WIP unauth support chat

This commit is contained in:
nflnkr 2023-04-13 19:48:17 +03:00
parent d8934c6e07
commit 53c07c7f78
14 changed files with 468 additions and 25 deletions

@ -36,7 +36,21 @@ export function subscribeToTicketMessages({ onMessage, onError, accessToken, tic
};
}
export async function getTickets({ body, signal }: {
export function subscribeToUnauthTicketMessages({ onMessage, onError, sessionId }: {
sessionId: string;
onMessage: (e: MessageEvent) => void;
onError: (e: Event) => void;
}) {
const url = `${supportApiUrl}/ticket?s=${sessionId}`;
const eventSource = createEventSource(onMessage, onError, url);
return () => {
eventSource.close();
};
}
export function getTickets({ body, signal }: {
body: GetTicketsRequest;
signal: AbortSignal;
}): Promise<GetTicketsResponse> {
@ -49,7 +63,22 @@ export async function getTickets({ body, signal }: {
});
}
export async function getTicketMessages({ body, signal }: {
export function getUnauthTicket(signal: AbortSignal): Promise<GetTicketsResponse> {
return makeRequest<GetTicketsRequest, GetTicketsResponse>({
url: `${supportApiUrl}/getTickets`,
method: "POST",
useToken: false,
body: {
amt: 1,
page: 0,
status: "open",
},
signal,
withCredentials: true,
});
}
export function getTicketMessages({ body, signal }: {
body: GetMessagesRequest;
signal: AbortSignal;
}): Promise<GetMessagesResponse> {
@ -62,20 +91,35 @@ export async function getTicketMessages({ body, signal }: {
});
}
export async function sendTicketMessage(body: SendTicketMessageRequest) {
export function getUnauthTicketMessages({ body, signal }: {
body: GetMessagesRequest;
signal: AbortSignal;
}): Promise<GetMessagesResponse> {
return makeRequest({
url: `${supportApiUrl}/send`,
url: `${supportApiUrl}/getMessages`,
method: "POST",
useToken: true,
useToken: false,
body,
signal,
withCredentials: true,
});
}
export async function createTicket(body: CreateTicketRequest):Promise<CreateTicketResponse> {
export function sendTicketMessage(body: SendTicketMessageRequest, withCredentials?: boolean) {
return makeRequest({
url: `${supportApiUrl}/send`,
method: "POST",
useToken: !withCredentials,
body,
withCredentials,
});
}
export function createTicket(body: CreateTicketRequest, useToken = true): Promise<CreateTicketResponse> {
return makeRequest({
url: `${supportApiUrl}/create`,
method: "POST",
useToken: true,
useToken,
body,
});
}

@ -2,15 +2,18 @@ import { Box, Typography, useMediaQuery, useTheme } from "@mui/material";
interface Props {
unAuthenticated?: boolean;
isSelf: boolean;
text: string;
time: string;
}
export default function Message({ isSelf, text, time }: Props) {
export default function ChatMessage({ unAuthenticated = false, isSelf, text, time }: Props) {
const theme = useTheme();
const upMd = useMediaQuery(theme.breakpoints.up("md"));
const messageBackgroundColor = isSelf ? "white" : unAuthenticated ? "#EFF0F5" : theme.palette.grey2.main;
return (
<Box
sx={{
@ -34,13 +37,13 @@ export default function Message({ isSelf, text, time }: Props) {
>{time}</Typography>
<Box
sx={{
backgroundColor: isSelf ? "white" : theme.palette.grey2.main,
border: `1px solid ${theme.palette.grey2.main}`,
backgroundColor: messageBackgroundColor,
border: unAuthenticated ? `1px solid #E3E3E3` : `1px solid ${theme.palette.grey2.main}`,
order: isSelf ? 2 : 1,
p: upMd ? "18px" : "12px",
borderRadius: "8px",
maxWidth: "464px",
color: isSelf ? theme.palette.grey3.main : "white",
color: (isSelf || unAuthenticated) ? theme.palette.grey3.main : "white",
position: "relative",
}}
>
@ -60,10 +63,10 @@ export default function Message({ isSelf, text, time }: Props) {
>
<path d="M0.5 0.5L15.5 0.500007
C10 0.500006 7.5 8 7.5 7.5H7.5H0.5V0.5Z"
fill={isSelf ? "white" : theme.palette.grey2.main}
stroke={theme.palette.grey2.main}
fill={messageBackgroundColor}
stroke={unAuthenticated ? "#E3E3E3" : theme.palette.grey2.main}
/>
<rect y="1" width="8" height="8" fill={isSelf ? "white" : theme.palette.grey2.main} />
<rect y="1" width="8" height="8" fill={messageBackgroundColor} />
</svg>
<Typography
sx={{

@ -0,0 +1,218 @@
import { Box, FormControl, IconButton, InputAdornment, InputBase, SxProps, Theme, Typography, useMediaQuery, useTheme } from "@mui/material";
import { createTicket, getTicketMessages, getUnauthTicket, getUnauthTicketMessages, sendTicketMessage, subscribeToUnauthTicketMessages } from "@root/api/tickets";
import { GetMessagesRequest, TicketMessage } from "@root/model/ticket";
import { useMessageStore } from "@root/stores/messages";
import { addOrUpdateUnauthMessages, setUnauthTicket, setUnauthTicketFetchState, setUnauthTicketSessionId, useUnauthTicketStore } from "@root/stores/unauthTicket";
import { enqueueSnackbar } from "notistack";
import { useEffect, useRef, useState } from "react";
import ChatMessage from "../ChatMessage";
import SendIcon from "../icons/SendIcon";
import UserCircleIcon from "./UserCircleIcon";
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 sessionId = useUnauthTicketStore(state => state.sessionId);
const messages = useUnauthTicketStore(state => state.messages);
const messageApiPage = useUnauthTicketStore(state => state.apiPage);
const messagesPerPage = useUnauthTicketStore(state => state.messagesPerPage);
const messagesFetchStateRef = useRef(useMessageStore.getState().fetchState);
const chatBoxRef = useRef<HTMLDivElement>();
useEffect(function fetchTicketMessages() {
if (!sessionId) return;
const getTicketsBody: GetMessagesRequest = {
amt: messagesPerPage,
page: messageApiPage,
ticket: sessionId,
};
const controller = new AbortController();
setUnauthTicketFetchState("fetching");
getUnauthTicketMessages({
body: getTicketsBody,
signal: controller.signal,
}).then(result => {
console.log("GetMessagesResponse", result);
if (result?.length > 0) {
addOrUpdateUnauthMessages(result);
setUnauthTicketFetchState("idle");
} else setUnauthTicketFetchState("all fetched");
}).catch(error => {
console.log("Error fetching messages", error);
enqueueSnackbar(error.message);
});
return () => {
controller.abort();
};
}, [messageApiPage, messagesPerPage, sessionId]);
useEffect(function subscribeToMessages() {
if (!sessionId) return;
const unsubscribe = subscribeToUnauthTicketMessages({
sessionId,
onMessage(event) {
try {
const newMessage = JSON.parse(event.data) as TicketMessage;
// console.log("SSE: parsed newMessage:", newMessage);
addOrUpdateUnauthMessages([newMessage]);
} catch (error) {
// console.log("SSE: couldn't parse:", event.data);
// console.log("Error parsing message SSE", error);
}
},
onError(event) {
// console.log("SSE Error:", event);
},
});
return () => {
unsubscribe();
// clearUnauthTicketState();
};
}, [sessionId]);
useEffect(() => useMessageStore.subscribe(state => (messagesFetchStateRef.current = state.fetchState)), []);
async function handleSendMessage() {
if (!messageField) return;
if (!sessionId) {
const response = await createTicket({
Title: "Unauth title",
Message: messageField,
}, false);
setUnauthTicketSessionId(response.sess);
} else {
sendTicketMessage({
ticket: sessionId,
message: messageField,
lang: "ru",
files: [],
}, true);
}
setMessageField("");
}
return (
<Box sx={{
display: "flex",
flexDirection: "column",
width: "400px",
height: "600px",
backgroundColor: "#944FEE",
borderRadius: "8px",
...sx,
}}>
<Box sx={{
display: "flex",
gap: "9px",
pl: "22px",
pt: "12px",
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={{
height: "520px",
backgroundColor: "white",
mt: "auto",
borderRadius: "8px",
display: "flex",
flexDirection: "column",
}}>
<Box
ref={chatBoxRef}
sx={{
display: "flex",
width: "100%",
flexDirection: "column",
gap: upMd ? "20px" : "16px",
px: upMd ? "20px" : "5px",
py: upMd ? "20px" : "13px",
overflowY: "auto",
flexGrow: 1,
}}
>
{messages.map((message) => (
<ChatMessage
unAuthenticated
key={message.id}
text={message.message}
time={new Date(message.created_at).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}
isSelf={sessionId === message.user_id}
/>
))}
</Box>
<FormControl fullWidth sx={{ borderTop: "1px solid black" }}>
<InputBase
value={messageField}
fullWidth
placeholder="Введите сообщение..."
id="message"
multiline
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
onClick={handleSendMessage}
sx={{
height: "53px",
width: "53px",
mr: "13px",
p: 0,
}}
>
<SendIcon style={{
width: "100%",
height: "100%",
}} />
</IconButton>
</InputAdornment>
}
/>
</FormControl>
</Box>
</Box>
);
}

@ -0,0 +1,26 @@
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,37 @@
import { Alert, Avatar, Badge, Box, Button, Card, CardActionArea, CardContent, CardMedia, Checkbox, Chip, Container, Divider, Fab, FormControl, FormControlLabel, Icon, IconButton, InputAdornment, InputBase, InputLabel, LinearProgress, Link, List, ListItem, ListItemAvatar, ListItemButton, ListItemIcon, ListItemText, MenuItem, Pagination, Paper, RadioGroup, Rating, Select, SelectChangeEvent, Skeleton, Slider, Snackbar, Switch, SxProps, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Tabs, TextField, Theme, ToggleButton, ToggleButtonGroup, Tooltip, Typography, useMediaQuery, useTheme } from "@mui/material";
import { useState } from "react";
import CircleDoubleDown from "./CircleDoubleDown";
import Chat from "./Chat";
export default function FloatingSupportChat() {
const [isChatOpened, setIsChatOpened] = useState<boolean>(false);
return (
<Box sx={{
position: "fixed",
right: "20px",
bottom: "10px",
display: "flex",
}}>
{isChatOpened && <Chat sx={{
mb: "54px",
}} />}
<Fab
variant={"extended"}
onClick={() => setIsChatOpened(prev => !prev)}
sx={{
pl: "11px",
pr: !isChatOpened ? "15px" : "11px",
gap: "11px",
height: "54px",
borderRadius: "27px",
alignSelf: "end",
}}
>
<CircleDoubleDown isUp={isChatOpened} />
{!isChatOpened && "Задайте нам вопрос"}
</Fab>
</Box>
);
}

@ -0,0 +1,22 @@
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>
);
}

@ -1,9 +1,14 @@
import { CSSProperties } from "react";
export default function SendIcon() {
interface Props {
style?: CSSProperties;
}
export default function SendIcon({ style }: Props) {
return (
<svg xmlns="http://www.w3.org/2000/svg" width="45" height="45" viewBox="0 0 45 45" fill="none">
<svg xmlns="http://www.w3.org/2000/svg" width="45" height="45" viewBox="0 0 45 45" fill="none" style={style}>
<circle cx="22.5" cy="22.5" r="22.5" fill="#944FEE" />
<path d="M33.8489 22.1816L15.9232 12.1415C15.7722 12.0581 15.5994 12.0227 15.4277 12.0399C15.2561 12.0571 15.0938 12.1263 14.9624 12.2381C14.831 12.3498 14.7368 12.499 14.6923 12.6657C14.6478 12.8323 14.6551 13.0086 14.7133 13.171L18.0883 22.638C18.1627 22.8218 18.1627 23.0273 18.0883 23.2111L14.7133 32.6781C14.6551 32.8405 14.6478 33.0167 14.6923 33.1834C14.7368 33.3501 14.831 33.4992 14.9624 33.611C15.0938 33.7228 15.2561 33.7919 15.4277 33.8092C15.5994 33.8264 15.7722 33.791 15.9232 33.7076L33.8489 23.6675C33.9816 23.594 34.0922 23.4864 34.1693 23.3558C34.2463 23.2251 34.2869 23.0762 34.2869 22.9245C34.2869 22.7729 34.2463 22.624 34.1693 22.4933C34.0922 22.3627 33.9816 22.255 33.8489 22.1816V22.1816Z" stroke="white" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
<path d="M18.1943 22.9248H24.9868" stroke="white" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />

@ -1,19 +1,24 @@
import { Box } from "@mui/material";
import Section1 from "./Section1";
import Section2 from "./Section2";
import Section3 from "./Section3";
import Section4 from "./Section4";
import Section5 from "./Section5";
import FloatingSupportChat from "@root/components/FloatingSupportChat/FloatingSupportChat";
export default function Landing() {
return (
<main>
<Box sx={{
position: "relative",
}}>
<Section1 />
<Section2 />
<Section3 />
<Section4 />
<Section5 />
</main>
<FloatingSupportChat />
</Box>
);
}

@ -3,7 +3,7 @@ import ArrowBackIcon from "@mui/icons-material/ArrowBack";
import { useParams } from "react-router-dom";
import SectionWrapper from "@components/SectionWrapper";
import ComplexNavText from "@components/ComplexNavText";
import SupportChat from "./Chat/SupportChat";
import SupportChat from "./SupportChat";
import CreateTicket from "./CreateTicket";
import TicketList from "./TicketList/TicketList";
import { useEffect } from "react";

@ -4,7 +4,6 @@ import { useEffect, useRef, useState } from "react";
import { useParams } from "react-router-dom";
import CustomButton from "@components/CustomButton";
import SendIcon from "@components/icons/SendIcon";
import Message from "./Message";
import { throttle } from "@utils/decorators";
import { enqueueSnackbar } from "notistack";
import { useTicketStore } from "@root/stores/tickets";
@ -12,6 +11,7 @@ import { addOrUpdateMessages, clearMessageState, incrementMessageApiPage, setIsP
import { getTicketMessages, sendTicketMessage, subscribeToTicketMessages } from "@root/api/tickets";
import { GetMessagesRequest, TicketMessage } from "@root/model/ticket";
import { authStore } from "@root/stores/makeRequest";
import ChatMessage from "@root/components/ChatMessage";
export default function SupportChat() {
@ -247,7 +247,7 @@ export default function SupportChat() {
}}
>
{ticket && messages.map((message) => (
<Message
<ChatMessage
key={message.id}
text={message.message}
time={new Date(message.created_at).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}

@ -18,6 +18,7 @@ interface FirstRequest<T> {
useToken?: boolean;
contentType?: boolean;
signal?: AbortSignal;
withCredentials?: boolean;
}
export const authStore = create<AuthStore>()(
@ -51,15 +52,16 @@ async function makeRequest<TRequest, TResponse>({
HC,
token,
signal,
withCredentials,
}: MakeRequest<TRequest>) {
//В случае 401 рефреш должен попробовать вызваться 1 раз
let headers: any = {};
if (useToken) headers["Authorization"] = token;
if (contentType) headers["Content-Type"] = "application/json";
if (contentType) headers["Content-Type"] = "*/*";
try {
const response = await axios<TRequest, AxiosResponse<TResponse & { accessToken?: string; }>>(
{ url, method, headers, data: body, signal }
{ url, method, headers, data: body, signal, withCredentials }
);
if (response.data?.accessToken) {
@ -68,7 +70,7 @@ async function makeRequest<TRequest, TResponse>({
return response.data;
} catch (error: any) {
if (error?.response?.status === 401) {
if (error?.response?.status === 401 && !withCredentials) {
const refreshResponse = await refresh();
if (refreshResponse.data?.accessToken) HC(refreshResponse.data.accessToken);

@ -28,7 +28,7 @@ export const useTicketStore = create<TicketStore>()(
export const setTicketCount = (ticketCount: number) => useTicketStore.setState({ ticketCount });
export const setTicketsFetchState = (fetchState: "idle" | "fetching" | "all fetched") => useTicketStore.setState({ fetchState });
export const setTicketsFetchState = (fetchState: TicketStore["fetchState"]) => useTicketStore.setState({ fetchState });
export const setTicketApiPage = (apiPage: number) => useTicketStore.setState({ apiPage: apiPage });

@ -0,0 +1,75 @@
import { Ticket, TicketMessage } from "@root/model/ticket";
import { create } from "zustand";
import { createJSONStorage, devtools, persist } from "zustand/middleware";
interface UnauthTicketStore {
ticket: Ticket | null;
sessionId: string | null;
messages: TicketMessage[];
fetchState: "idle" | "fetching" | "all fetched";
apiPage: number;
messagesPerPage: number;
}
export const useUnauthTicketStore = create<UnauthTicketStore>()(
persist(
devtools(
(set, get) => ({
ticket: null,
sessionId: null,
messages: [],
fetchState: "idle",
apiPage: 0,
messagesPerPage: 10,
}),
{
name: "Unauth ticket store"
}
),
{
version: 0,
name: "session",
storage: createJSONStorage(() => localStorage),
partialize: state => ({
sessionId: state.sessionId,
})
}
)
);
export const setUnauthTicket = (ticket: Ticket) => useUnauthTicketStore.setState({ ticket });
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 });
};
export const clearUnauthTicketState = () => useUnauthTicketStore.setState({
ticket: null,
messages: [],
apiPage: 0,
fetchState: "idle",
});
export const setUnauthTicketFetchState = (fetchState: UnauthTicketStore["fetchState"]) => useUnauthTicketStore.setState({ fetchState });
export const incrementUnauthMessageApiPage = () => {
const state = useUnauthTicketStore.getState();
useUnauthTicketStore.setState({ apiPage: state.apiPage + 1 });
};
export const setUnauthTicketSessionId = (sessionId: string | null) => useUnauthTicketStore.setState({ sessionId });
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;
}

@ -31,6 +31,12 @@ const darkTheme = createTheme({
grey1: {
main: "#434657",
},
grey2: {
main: "#9A9AAF",
},
grey3: {
main: "#4D4D4D",
},
navbarbg: {
main: "#333647",
},