diff --git a/babel.config.js b/babel.config.js new file mode 100644 index 0000000..dd242dc --- /dev/null +++ b/babel.config.js @@ -0,0 +1,6 @@ +module.exports = { + presets: [ + ["@babel/preset-env", { targets: { node: "current" } }], + "@babel/preset-typescript", + ], +}; diff --git a/jest.config.js b/jest.config.js new file mode 100644 index 0000000..3abcbd9 --- /dev/null +++ b/jest.config.js @@ -0,0 +1,5 @@ +/** @type {import('ts-jest').JestConfigWithTsJest} */ +module.exports = { + preset: "ts-jest", + testEnvironment: "node", +}; diff --git a/package.json b/package.json index cf77475..35eca11 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "react-numeral": "^1.1.1", "react-router-dom": "^6.3.0", "react-scripts": "^5.0.1", + "reconnecting-eventsource": "^1.6.2", "styled-components": "^5.3.5", "typescript": "^4.8.2", "web-vitals": "^2.1.4", diff --git a/src/__tests__/tickets.test.ts b/src/__tests__/tickets.test.ts new file mode 100644 index 0000000..a526bf6 --- /dev/null +++ b/src/__tests__/tickets.test.ts @@ -0,0 +1,57 @@ +import axios from "axios"; + +const message = "Artem"; +describe("tests", () => { + let statusGetTickets: number; + let dataGetTickets: {}; + let statusGetMessages: number; + let dataGetMessages: []; + + beforeEach(async () => { + await axios({ + method: "post", + url: "https://admin.pena.digital/heruvym/getTickets", + data: { + amt: 20, + page: 0, + status: "open", + }, + }).then((result) => { + dataGetTickets = result.data; + statusGetTickets = result.status; + }); + + await axios({ + method: "post", + url: "https://admin.pena.digital/heruvym/getMessages", + data: { + amt: 100, + page: 0, + srch: "", + ticket: "cgg25qsvc9gd0bq9ne7g", + }, + }).then((result) => { + dataGetMessages = result.data; + statusGetMessages = result.status; + }); + }); + + // добавляем сообщения тикету с id cgg25qsvc9gd0bq9ne7g , вписываем текст в переменную message и проверяем тест + test("test sending messages to tickets", () => { + expect(statusGetTickets).toEqual(200); + // проверяем кличество тикетов отсалось неизменным + expect(dataGetTickets).toMatchObject({ count: 12 }); + + expect(statusGetMessages).toBe(200); + + expect(dataGetMessages[dataGetMessages.length - 1]).toMatchObject({ + files: [], + message: message, + request_screenshot: "", + session_id: "6421ccdad01874dcffa8b128", + shown: {}, + ticket_id: "cgg25qsvc9gd0bq9ne7g", + user_id: "6421ccdad01874dcffa8b128", + }); + }); +}); diff --git a/src/api/tickets.ts b/src/api/tickets.ts new file mode 100644 index 0000000..43f3aa0 --- /dev/null +++ b/src/api/tickets.ts @@ -0,0 +1,89 @@ +import makeRequest from "@root/kitUI/makeRequest"; +import { GetMessagesRequest, GetMessagesResponse, GetTicketsRequest, GetTicketsResponse, SendTicketMessageRequest } from "@root/model/ticket"; +import ReconnectingEventSource from "reconnecting-eventsource"; + + +const supportApiUrl = "https://admin.pena.digital/heruvym"; + +export function subscribeToAllTickets({ onMessage, onError, accessToken }: { + accessToken: string; + onMessage: (e: MessageEvent) => void; + onError: (e: Event) => void; +}) { + const url = `${supportApiUrl}/subscribe?Authorization=${accessToken}`; + + const eventSource = createEventSource(onMessage, onError, url); + + return () => { + eventSource.close(); + }; +} + +export function subscribeToTicketMessages({ onMessage, onError, accessToken, ticketId }: { + accessToken: string; + ticketId: string; + onMessage: (e: MessageEvent) => void; + onError: (e: Event) => void; +}) { + const url = `${supportApiUrl}/ticket?ticket=${ticketId}&Authorization=${accessToken}`; + + const eventSource = createEventSource(onMessage, onError, url); + + return () => { + eventSource.close(); + }; +} + +export async function getTickets({ body, signal }: { + body: GetTicketsRequest; + signal: AbortSignal; +}): Promise { + return makeRequest({ + url: `${supportApiUrl}/getTickets`, + method: "POST", + useToken: true, + body, + signal, + }).then(response => { + const result = (response as any).data as GetTicketsResponse; + return result; + }); +} + +export async function getTicketMessages({ body, signal }: { + body: GetMessagesRequest; + signal: AbortSignal; +}): Promise { + return makeRequest({ + url: `${supportApiUrl}/getMessages`, + method: "POST", + useToken: true, + body, + signal, + }).then(response => { + const result = (response as any).data as GetMessagesResponse; + return result; + }); +} + +export async function sendTicketMessage({ body }: { + body: SendTicketMessageRequest; +}) { + return makeRequest({ + url: `${supportApiUrl}/send`, + method: "POST", + useToken: true, + body, + }); +} + +function createEventSource(onMessage: (e: MessageEvent) => void, onError: (e: Event) => void, url: string) { + const eventSource = new ReconnectingEventSource(url); + + eventSource.addEventListener("open", () => console.log(`EventSource connected with ${url}`)); + eventSource.addEventListener("close", () => console.log(`EventSource closed with ${url}`)); + eventSource.addEventListener("message", onMessage); + eventSource.addEventListener("error", onError); + + return eventSource; +} \ No newline at end of file diff --git a/src/fonts/GilroyRegular.woff b/src/fonts/GilroyRegular.woff new file mode 100644 index 0000000..65da963 Binary files /dev/null and b/src/fonts/GilroyRegular.woff differ diff --git a/src/index.css b/src/index.css new file mode 100644 index 0000000..b958aa5 --- /dev/null +++ b/src/index.css @@ -0,0 +1,4 @@ +@font-face { + font-family: "GilroyRegular"; + src: local("GilroyRegular"), url(./fonts/GilroyRegular.woff) format("woff"); +} \ No newline at end of file diff --git a/src/index.tsx b/src/index.tsx index 6efa6e9..8817b12 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -18,7 +18,9 @@ import Entities from "@pages/dashboard/Content/Entities"; import Tariffs from "@pages/dashboard/Content/Tariffs"; import DiscountManagement from "@pages/dashboard/Content/DiscountManagement"; import PromocodeManagement from "@pages/dashboard/Content/PromocodeManagement"; -import Support from "@pages/dashboard/Content/Support"; +import Support from "@root/pages/dashboard/Content/Support/Support"; +import "./index.css"; + const componentsArray = [ ["/users", ], @@ -26,7 +28,8 @@ const componentsArray = [ ["/tariffs", ], ["/discounts", ], ["/promocode", ], - ["/support", ] + ["/support", ], + ["/support/:ticketId", ], ] const container = document.getElementById('root'); diff --git a/src/kitUI/makeRequest.ts b/src/kitUI/makeRequest.ts index f605f24..64d9798 100644 --- a/src/kitUI/makeRequest.ts +++ b/src/kitUI/makeRequest.ts @@ -5,6 +5,7 @@ interface MakeRequest { body?: unknown useToken?: boolean contentType?: boolean + signal?: AbortSignal } export default (props: MakeRequest) => { @@ -22,6 +23,7 @@ function makeRequest({ url, body, useToken = true, + signal, contentType = false }: MakeRequest) { //В случае 401 рефреш должен попробовать вызваться 1 раз @@ -33,7 +35,8 @@ function makeRequest({ url: url, method: method, headers: headers, - data: body + data: body, + signal, }) .then(response => { if (response.data && response.data.accessToken) { diff --git a/src/model/ticket.ts b/src/model/ticket.ts new file mode 100644 index 0000000..27ffaf0 --- /dev/null +++ b/src/model/ticket.ts @@ -0,0 +1,66 @@ + + +export interface CreateTicketRequest { + Title: string; + Message: string; +}; + +export interface CreateTicketResponse { + Ticket: string; +}; + +export interface SendTicketMessageRequest { + message: string; + ticket: string; + lang: string; + files: string[]; +}; + +export type TicketStatus = "open"; // TODO + +export interface GetTicketsRequest { + amt: number; + /** Пагинация начинается с индекса 0 */ + page: number; + srch?: string; + status?: TicketStatus; +}; + +export interface GetTicketsResponse { + count: number; + data: Ticket[] | null; +}; + +export interface Ticket { + id: string; + user: string; + sess: string; + ans: string; + state: string; + top_message: TicketMessage; + title: string; + created_at: string; + updated_at: string; + rate: number; +}; + +export interface TicketMessage { + id: string; + ticket_id: string; + user_id: string, + session_id: string; + message: string; + files: string[], + shown: { [key: string]: number; }, + request_screenshot: string, + created_at: string; +}; + +export interface GetMessagesRequest { + amt: number; + page: number; + srch?: string; + ticket: string; +}; + +export type GetMessagesResponse = TicketMessage[]; diff --git a/src/pages/dashboard/Content/Support/Chat/Chat.tsx b/src/pages/dashboard/Content/Support/Chat/Chat.tsx new file mode 100644 index 0000000..3d8ad17 --- /dev/null +++ b/src/pages/dashboard/Content/Support/Chat/Chat.tsx @@ -0,0 +1,213 @@ +import { Box, IconButton, InputAdornment, TextField, Typography, useMediaQuery, useTheme } from "@mui/material"; +import { addOrUpdateMessages, clearMessages, setMessages, useMessageStore } from "@root/stores/messages"; +import Message from "./Message"; +import SendIcon from "@mui/icons-material/Send"; +import AttachFileIcon from "@mui/icons-material/AttachFile"; +import { KeyboardEvent, useEffect, useRef, useState } from "react"; +import { useParams } from "react-router-dom"; +import { GetMessagesRequest, TicketMessage } from "@root/model/ticket"; +import { getTicketMessages, sendTicketMessage, subscribeToTicketMessages } from "@root/api/tickets"; +import { enqueueSnackbar } from "notistack"; +import { useTicketStore } from "@root/stores/tickets"; + + +export default function Chat() { + const theme = useTheme(); + const upMd = useMediaQuery(theme.breakpoints.up("md")); + const tickets = useTicketStore(state => state.tickets); + const messages = useMessageStore(state => state.messages); + const [messageField, setMessageField] = useState(""); + const ticketId = useParams().ticketId; + const chatBoxRef = useRef(null); + + const ticket = tickets.find(ticket => ticket.id === ticketId); + + useEffect(function scrollOnNewMessage() { + scrollToBottom(); + }, [messages]); + + useEffect(function fetchTicketMessages() { + if (!ticketId) return; + + const getTicketsBody: GetMessagesRequest = { + amt: 100, // TODO use pagination + page: 0, + ticket: ticketId, + }; + const controller = new AbortController(); + + getTicketMessages({ + body: getTicketsBody, + signal: controller.signal, + }).then(result => { + console.log("GetMessagesResponse", result); + setMessages(result); + }).catch(error => { + console.log("Error fetching tickets", error); + enqueueSnackbar(error.message); + }); + + return () => { + controller.abort(); + clearMessages(); + }; + }, [ticketId]); + + useEffect(function subscribeToMessages() { + if (!ticketId) return; + + const token = localStorage.getItem("AT"); + if (!token) return; + + const unsubscribe = subscribeToTicketMessages({ + ticketId, + accessToken: token, + onMessage(event) { + try { + const newMessage = JSON.parse(event.data) as TicketMessage; + console.log("SSE: parsed newMessage:", newMessage); + addOrUpdateMessages([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(); + }; + }, [ticketId]); + + function scrollToBottom() { + if (!chatBoxRef.current) return; + + chatBoxRef.current.scroll({ + left: 0, + top: chatBoxRef.current.scrollHeight, + behavior: "smooth", + }); + } + + function handleSendMessage() { + if (!ticket || !messageField) return; + + sendTicketMessage({ + body: { + files: [], + lang: "ru", + message: messageField, + ticket: ticket.id, + } + }); + setMessageField(""); + } + + function handleAddAttachment() { + + } + + function handleTextfieldKeyPress(e: KeyboardEvent) { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + handleSendMessage(); + } + } + + const sortedMessages = messages.sort(sortMessagesByTime); + + return ( + + {ticket ? + <> + {ticket.title} + + {sortedMessages.map(message => + + )} + + setMessageField(e.target.value)} + onKeyPress={handleTextfieldKeyPress} + id="message-input" + placeholder="Написать сообщение" + fullWidth + multiline + maxRows={8} + InputProps={{ + style: { + backgroundColor: theme.palette.content.main, + color: theme.palette.secondary.main, + }, + endAdornment: ( + + + + + + + + + ) + }} + InputLabelProps={{ + style: { + color: theme.palette.secondary.main, + } + }} + /> + + : + Выберите тикет} + + ); +} + +function sortMessagesByTime(message1: TicketMessage, message2: TicketMessage) { + const date1 = new Date(message1.created_at).getTime(); + const date2 = new Date(message2.created_at).getTime(); + return date1 - date2; +} \ No newline at end of file diff --git a/src/pages/dashboard/Content/Support/Chat/Message.tsx b/src/pages/dashboard/Content/Support/Chat/Message.tsx new file mode 100644 index 0000000..e08b357 --- /dev/null +++ b/src/pages/dashboard/Content/Support/Chat/Message.tsx @@ -0,0 +1,45 @@ +import { Box, Typography, useTheme } from "@mui/material"; +import { TicketMessage } from "@root/model/ticket"; + + +interface Props { + message: TicketMessage; + isSelf?: boolean; +} + +export default function Message({ message, isSelf }: Props) { + const theme = useTheme(); + + const time = ( + + {new Date(message.created_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} + + ); + + return ( + + {isSelf && time} + + + {message.message} + + + {!isSelf && time} + + ); +} \ No newline at end of file diff --git a/src/pages/dashboard/Content/Support/Collapse.tsx b/src/pages/dashboard/Content/Support/Collapse.tsx new file mode 100644 index 0000000..5aa9870 --- /dev/null +++ b/src/pages/dashboard/Content/Support/Collapse.tsx @@ -0,0 +1,53 @@ +import { ReactNode, useState } from "react"; +import { Box, Typography, useTheme } from "@mui/material"; +import ExpandIcon from "./ExpandIcon"; + + +interface Props { + headerText: string; + children: ReactNode; +} + +export default function Collapse({ headerText, children }: Props) { + const theme = useTheme(); + const [isExpanded, setIsExpanded] = useState(false); + + return ( + + setIsExpanded(prev => !prev)} + sx={{ + height: "72px", + p: "16px", + backgroundColor: theme.palette.menu.main, + borderRadius: "12px", + + display: "flex", + justifyContent: "space-between", + alignItems: "center", + cursor: "pointer", + userSelect: "none", + }} + > + {headerText} + + + {isExpanded && + + {children} + + } + + + ); +} \ No newline at end of file diff --git a/src/pages/dashboard/Content/Support/ExpandIcon.tsx b/src/pages/dashboard/Content/Support/ExpandIcon.tsx new file mode 100644 index 0000000..eb6502f --- /dev/null +++ b/src/pages/dashboard/Content/Support/ExpandIcon.tsx @@ -0,0 +1,17 @@ +import { useTheme } from "@mui/material"; + + +interface Props { + isExpanded: boolean; +} + +export default function ExpandIcon({ isExpanded }: Props) { + const theme = useTheme(); + + return ( + + + + + ); +} diff --git a/src/pages/dashboard/Content/Support/Pagination/index.tsx b/src/pages/dashboard/Content/Support/Pagination/index.tsx deleted file mode 100644 index 6201c61..0000000 --- a/src/pages/dashboard/Content/Support/Pagination/index.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import * as React from "react"; -import { Box, Pagination } from "@mui/material"; -import theme from "../../../../../theme"; - - -const Users: React.FC = () => { - return ( - - - - - - - - ); -} - - -export default Users; \ No newline at end of file diff --git a/src/pages/dashboard/Content/Support/Support.tsx b/src/pages/dashboard/Content/Support/Support.tsx new file mode 100644 index 0000000..361dfc9 --- /dev/null +++ b/src/pages/dashboard/Content/Support/Support.tsx @@ -0,0 +1,95 @@ +import { Box, useMediaQuery, useTheme } from "@mui/material"; +import { useEffect, useRef, useState } from "react"; +import Chat from "./Chat/Chat"; +import Collapse from "./Collapse"; +import TicketList from "./TicketList/TicketList"; +import { getTickets, subscribeToAllTickets } from "@root/api/tickets"; +import { GetTicketsRequest, Ticket } from "@root/model/ticket"; +import { clearTickets, updateTickets } from "@root/stores/tickets"; +import { enqueueSnackbar } from "notistack"; +import { clearMessages } from "@root/stores/messages"; + + +const TICKETS_PER_PAGE = 20; + +export default function Support() { + const theme = useTheme(); + const upMd = useMediaQuery(theme.breakpoints.up("md")); + const [currentPage, setCurrentPage] = useState(0); + const fetchingStateRef = useRef<"idle" | "fetching" | "all fetched">("idle"); + + useEffect(function fetchTickets() { + const getTicketsBody: GetTicketsRequest = { + amt: TICKETS_PER_PAGE, + page: currentPage, + status: "open", + }; + const controller = new AbortController(); + + fetchingStateRef.current = "fetching"; + getTickets({ + body: getTicketsBody, + signal: controller.signal, + }).then(result => { + console.log("GetTicketsResponse", result); + if (result.data) { + updateTickets(result.data); + fetchingStateRef.current = "idle"; + } else fetchingStateRef.current = "all fetched"; + }).catch(error => { + console.log("Error fetching tickets", error); + enqueueSnackbar(error.message); + }); + + return () => controller.abort(); + }, [currentPage]); + + useEffect(function subscribeToTickets() { + const token = localStorage.getItem("AT"); + if (!token) return; + + const unsubscribe = subscribeToAllTickets({ + accessToken: token, + onMessage(event) { + try { + const newTicket = JSON.parse(event.data) as Ticket; + console.log("SSE: parsed newTicket:", newTicket); + updateTickets([newTicket]); + } catch (error) { + console.log("SSE: couldn't parse:", event.data); + console.log("Error parsing ticket SSE", error); + } + }, + onError(event) { + console.log("SSE Error:", event); + } + }); + + return () => { + unsubscribe(); + clearMessages(); + clearTickets(); + }; + }, []); + + const incrementCurrentPage = () => setCurrentPage(prev => prev + 1); + + const ticketList = ; + + return ( + + {!upMd && + + {ticketList} + + } + + {upMd && ticketList} + + ); +} \ No newline at end of file diff --git a/src/pages/dashboard/Content/Support/TicketList/TicketItem.tsx b/src/pages/dashboard/Content/Support/TicketList/TicketItem.tsx new file mode 100644 index 0000000..b0366f8 --- /dev/null +++ b/src/pages/dashboard/Content/Support/TicketList/TicketItem.tsx @@ -0,0 +1,91 @@ +import CircleIcon from "@mui/icons-material/Circle"; +import { Box, Card, CardActionArea, CardContent, CardHeader, Divider, Typography, useTheme } from "@mui/material"; +import { green } from "@mui/material/colors"; +import { Ticket } from "@root/model/ticket"; +import { useNavigate, useParams } from "react-router-dom"; + + +const flexCenterSx = { + textAlign: "center", + display: "flex", + justifyContent: "center", + alignItems: "center", + padding: "10px", +}; + +interface Props { + ticket: Ticket; +} + +export default function TicketItem({ ticket }: Props) { + const theme = useTheme(); + const navigate = useNavigate(); + const ticketId = useParams().ticketId; + + const isUnread = ticket.user === ticket.top_message.user_id; + const isSelected = ticket.id === ticketId; + + const unreadSx = { + border: "1px solid", + borderColor: theme.palette.golden.main, + backgroundColor: theme.palette.goldenMedium.main + }; + + const selectedSx = { + border: `2px solid ${theme.palette.secondary.main}`, + }; + + function handleCardClick() { + navigate(`/support/${ticket.id}`); + } + + return ( + + + {ticket.title}} + disableTypography + sx={{ + textAlign: "center", + p: "4px", + }} + /> + + + + {new Date(ticket.top_message.created_at).toLocaleDateString()} + + + {ticket.top_message.message} + + + + + + ИНФО + + + + + ); +} \ No newline at end of file diff --git a/src/pages/dashboard/Content/Support/TicketList/TicketList.tsx b/src/pages/dashboard/Content/Support/TicketList/TicketList.tsx new file mode 100644 index 0000000..0896e60 --- /dev/null +++ b/src/pages/dashboard/Content/Support/TicketList/TicketList.tsx @@ -0,0 +1,126 @@ +import HighlightOffOutlinedIcon from '@mui/icons-material/HighlightOffOutlined'; +import SearchOutlinedIcon from '@mui/icons-material/SearchOutlined'; +import { Box, Button, useMediaQuery, useTheme } from "@mui/material"; +import { Ticket } from "@root/model/ticket"; +import { useTicketStore } from "@root/stores/tickets"; +import { throttle } from '@root/utils/throttle'; +import { MutableRefObject, useEffect, useRef } from "react"; +import TicketItem from "./TicketItem"; + + +interface Props { + fetchingStateRef: MutableRefObject<"idle" | "fetching" | "all fetched">; + incrementCurrentPage: () => void; +} + +export default function TicketList({ fetchingStateRef, incrementCurrentPage }: Props) { + const theme = useTheme(); + const upMd = useMediaQuery(theme.breakpoints.up("md")); + const tickets = useTicketStore(state => state.tickets); + const ticketsBoxRef = useRef(null); + + useEffect(function updateCurrentPageOnScroll() { + if (!ticketsBoxRef.current) return; + + const ticketsBox = ticketsBoxRef.current; + const scrollHandler = () => { + const scrollBottom = ticketsBox.scrollHeight - ticketsBox.scrollTop - ticketsBox.clientHeight; + if ( + scrollBottom < 10 && + fetchingStateRef.current === "idle" + ) incrementCurrentPage(); + }; + + const throttledScrollHandler = throttle(scrollHandler, 200); + ticketsBox.addEventListener("scroll", throttledScrollHandler); + + return () => { + ticketsBox.removeEventListener("scroll", throttledScrollHandler); + }; + }, [incrementCurrentPage, fetchingStateRef]); + + const sortedTickets = tickets.sort(sortTicketsByUpdateTime).sort(sortTicketsByUnread); + + return ( + + + + + + + {sortedTickets.map(ticket => + + )} + + + ); +} + +function sortTicketsByUpdateTime(ticket1: Ticket, ticket2: Ticket) { + const date1 = new Date(ticket1.updated_at).getTime(); + const date2 = new Date(ticket2.updated_at).getTime(); + return date2 - date1; +} + +function sortTicketsByUnread(ticket1: Ticket, ticket2: Ticket) { + const isUnread1 = ticket1.user === ticket1.top_message.user_id; + const isUnread2 = ticket2.user === ticket2.top_message.user_id; + return Number(isUnread2) - Number(isUnread1); +} \ No newline at end of file diff --git a/src/pages/dashboard/Content/Support/index.tsx b/src/pages/dashboard/Content/Support/index.tsx deleted file mode 100644 index ed35c8f..0000000 --- a/src/pages/dashboard/Content/Support/index.tsx +++ /dev/null @@ -1,306 +0,0 @@ -import * as React from "react"; -import { Box, Button } from "@mui/material"; -import SearchOutlinedIcon from '@mui/icons-material/SearchOutlined'; -import HighlightOffOutlinedIcon from '@mui/icons-material/HighlightOffOutlined'; -import CircleIcon from '@mui/icons-material/Circle'; -import theme from "../../../../theme"; -import { green } from '@mui/material/colors'; -import Pagination from "./Pagination"; - - -const Users: React.FC = () => { - return ( - - - - - - - - - - - - - - - 10.09.2022 - - - ДЕНЬГИ НЕ ПРИШЛИ - - - - - - ИНФО - - - - - - 09.09.2022 - - - ВЫВОД - - - - - - ИНФО - - - - - - 09.09.2022 - - - ЗДРАВСТВУЙТЕ, МОЖНО ЛИ ОПЛАТИТЬ ЛИЦОМ НЕ ДОСТИГШИМ 18 ЛЕТ, ОПЛАТИТЬ 300 РУБЛЕЙ ЧЕРЕЗ КИВИ - - - - - - ИНФО - - - - - - 07.09.2022 - - - ПРОБЛЕМЫ С ВЫВОДОМ - - - - - - ИНФО - - - - - - - - - - - ); -} - - -export default Users; \ No newline at end of file diff --git a/src/stores/messages.ts b/src/stores/messages.ts new file mode 100644 index 0000000..bffbee9 --- /dev/null +++ b/src/stores/messages.ts @@ -0,0 +1,33 @@ +import { TicketMessage } from "@root/model/ticket"; +import { create } from "zustand"; +import { devtools } from "zustand/middleware"; +import { testMessages } from "./mocks/messages"; + + +interface MessageStore { + messages: TicketMessage[]; +} + +export const useMessageStore = create()( + devtools( + (set, get) => ({ + messages: testMessages, + }), + { + name: "Message store (admin)" + } + ) +); + +export const setMessages = (messages: TicketMessage[]) => useMessageStore.setState(({ messages })); + +export const addOrUpdateMessages = (receivedMessages: TicketMessage[]) => { + const state = useMessageStore.getState(); + const messageIdToMessageMap: { [messageId: string]: TicketMessage; } = {}; + + [...state.messages, ...receivedMessages].forEach(message => messageIdToMessageMap[message.id] = message); + + useMessageStore.setState({ messages: Object.values(messageIdToMessageMap) }); +}; + +export const clearMessages = () => useMessageStore.setState({ messages: [] }); \ No newline at end of file diff --git a/src/stores/mocks/messages.ts b/src/stores/mocks/messages.ts new file mode 100644 index 0000000..76b5997 --- /dev/null +++ b/src/stores/mocks/messages.ts @@ -0,0 +1,94 @@ +import { TicketMessage } from "@root/model/ticket"; +import { nanoid } from "nanoid"; + + +export const testMessages: TicketMessage[] = [ + { + "id": nanoid(), + "ticket_id": "cg5irh4vc9g7b3n3tcrg", + "user_id": "6407625ed01874dcffa8b008", + "session_id": "6407625ed01874dcffa8b008", + "message": "Lorem ipsum", + "files": [], + "shown": {}, + "request_screenshot": "", + "created_at": "2023-03-09T12:16:52.73Z" + }, + { + "id": nanoid(), + "ticket_id": "cg5irh4vc9g7b3n3tcrg", + "user_id": "6407625ed01874dcffa8b008", + "session_id": "6407625ed01874dcffa8b008", + "message": "Lorem ipsum dolor sit amet, consectetur adipiscing elit", + "files": [], + "shown": {}, + "request_screenshot": "", + "created_at": "2023-03-10T15:51:52.73Z" + }, + { + "id": nanoid(), + "ticket_id": "cg5irh4vc9g7b3n3tcrg", + "user_id": "6407625ed01874dcffa8b008", + "session_id": "6407625ed01874dcffa8b008", + "message": "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut ", + "files": [], + "shown": {}, + "request_screenshot": "", + "created_at": "2023-03-10T19:23:52.73Z" + }, + { + "id": nanoid(), + "ticket_id": "cg5irh4vc9g7b3n3tcrg", + "user_id": "6407625ed01874dcffa8b008", + "session_id": "6407625ed01874dcffa8b008", + "message": "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore", + "files": [], + "shown": {}, + "request_screenshot": "", + "created_at": "2023-03-10T13:16:52.73Z" + }, + { + "id": nanoid(), + "ticket_id": "cg5irh4vc9g7b3n3tcrg", + "user_id": "6407625ed01874dcffa8b008", + "session_id": "6407625ed01874dcffa8b008", + "message": "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore", + "files": [], + "shown": {}, + "request_screenshot": "", + "created_at": "2023-03-10T13:16:52.73Z" + }, + { + "id": nanoid(), + "ticket_id": "cg5irh4vc9g7b3n3tcrg", + "user_id": "6407625ed01874dcffa8b008", + "session_id": "6407625ed01874dcffa8b008", + "message": "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore", + "files": [], + "shown": {}, + "request_screenshot": "", + "created_at": "2023-03-10T13:16:52.73Z" + }, + { + "id": nanoid(), + "ticket_id": "cg5irh4vc9g7b3n3tcrg", + "user_id": "6407625ed01874dcffa8b008", + "session_id": "6407625ed01874dcffa8b008", + "message": "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore", + "files": [], + "shown": {}, + "request_screenshot": "", + "created_at": "2023-03-10T13:16:52.73Z" + }, + { + "id": nanoid(), + "ticket_id": "cg5irh4vc9g7b3n3tcrg", + "user_id": "6407625ed01874dcffa8b008", + "session_id": "6407625ed01874dcffa8b008", + "message": "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore", + "files": [], + "shown": {}, + "request_screenshot": "", + "created_at": "2023-03-10T13:16:52.73Z" + }, +] \ No newline at end of file diff --git a/src/stores/mocks/tickets.ts b/src/stores/mocks/tickets.ts new file mode 100644 index 0000000..7af305b --- /dev/null +++ b/src/stores/mocks/tickets.ts @@ -0,0 +1,49 @@ +import { Ticket } from "@root/model/ticket"; + + +export const testTickets: Ticket[] = [ + { + "id": "cg5irh4vc9g7b3n3tcrg", + "user": "6407625ed01874dcffa8b008", + "sess": "6407625ed01874dcffa8b008", + "ans": "", + "state": "open", + "top_message": { + "id": "cg5irh4vc9g7b3n3tcs0", + "ticket_id": "cg5irh4vc9g7b3n3tcrg", + "user_id": "6407625ed01874dcffa8b008", + "session_id": "6407625ed01874dcffa8b008", + "message": "text", + "files": [], + "shown": {}, + "request_screenshot": "", + "created_at": "2023-03-10T13:16:52.73Z" + }, + "title": "textual ticket", + "created_at": "2023-03-10T13:16:52.73Z", + "updated_at": "2023-03-10T13:16:52.73Z", + "rate": -1 + }, + { + "id": "cg55nssvc9g7gddpnsug", + "user": "", + "sess": "", + "ans": "", + "state": "open", + "top_message": { + "id": "cg55nssvc9g7gddpnsv0", + "ticket_id": "cg55nssvc9g7gddpnsug", + "user_id": "", + "session_id": "", + "message": "text", + "files": [], + "shown": {}, + "request_screenshot": "", + "created_at": "2023-03-09T22:21:39.822Z" + }, + "title": "textual ticket", + "created_at": "2023-03-09T22:21:39.822Z", + "updated_at": "2023-03-09T22:21:39.822Z", + "rate": -1 + } +]; \ No newline at end of file diff --git a/src/stores/tickets.ts b/src/stores/tickets.ts new file mode 100644 index 0000000..e3a07cf --- /dev/null +++ b/src/stores/tickets.ts @@ -0,0 +1,30 @@ +import { Ticket } from "@root/model/ticket"; +import { create } from "zustand"; +import { devtools } from "zustand/middleware"; + + +interface TicketStore { + tickets: Ticket[]; +} + +export const useTicketStore = create()( + devtools( + (set, get) => ({ + tickets: [], + }), + { + name: "Tickets store (admin)" + } + ) +); + +export const updateTickets = (receivedTickets: Ticket[]) => { + const state = useTicketStore.getState(); + const ticketIdToTicketMap: { [ticketId: string]: Ticket; } = {}; + + [...state.tickets, ...receivedTickets].forEach(ticket => ticketIdToTicketMap[ticket.id] = ticket); + + useTicketStore.setState({ tickets: Object.values(ticketIdToTicketMap) }); +}; + +export const clearTickets = () => useTicketStore.setState({ tickets: [] }); \ No newline at end of file diff --git a/src/theme.ts b/src/theme.ts index fb2194c..73879c8 100644 --- a/src/theme.ts +++ b/src/theme.ts @@ -1,5 +1,5 @@ import { Theme } from '@mui/material/styles'; -import {createTheme, PaletteColorOptions} from "@mui/material"; +import { createTheme, PaletteColorOptions, ThemeOptions } from "@mui/material"; import { deepmerge } from '@mui/utils'; //import { createTheme } from "./types"; @@ -17,7 +17,7 @@ declare module '@mui/material/styles' { interface Theme { palette: { primary: { - main: string + main: string; }, secondary: { main: string; @@ -54,8 +54,8 @@ declare module '@mui/material/styles' { }, caption: { main: string; - } - } + }; + }; } interface PaletteOptions { @@ -125,8 +125,8 @@ const paletteColor = { main: "#2a2b1d" } }, -} -const theme = { +}; +const theme: ThemeOptions = { typography: { body1: { fontFamily: fontFamily @@ -211,9 +211,15 @@ const theme = { } } ] - } + }, + MuiButtonBase: { + styleOverrides: { + root: { + fontFamily, + fontSize: "16px", + } + }, + }, }, - - }; export default createTheme(deepmerge(paletteColor, theme)); \ No newline at end of file diff --git a/src/utils/throttle.ts b/src/utils/throttle.ts new file mode 100644 index 0000000..64a8c25 --- /dev/null +++ b/src/utils/throttle.ts @@ -0,0 +1,31 @@ + + +type ThrottledFunction any> = (...args: Parameters) => void; + +export function throttle any>(func: T, ms: number): ThrottledFunction { + let isThrottled = false; + let savedArgs: Parameters | null; + let savedThis: any; + + function wrapper(this: any, ...args: Parameters) { + if (isThrottled) { + savedArgs = args; + savedThis = this; + return; + } + + func.apply(this, args); + + isThrottled = true; + + setTimeout(function () { + isThrottled = false; + if (savedArgs) { + wrapper.apply(savedThis, savedArgs); + savedArgs = savedThis = null; + } + }, ms); + } + + return wrapper; +} \ No newline at end of file