From d94b23249f94be36d2ec5c8ab76672d8681eef72 Mon Sep 17 00:00:00 2001 From: nflnkr Date: Mon, 20 Mar 2023 18:24:20 +0300 Subject: [PATCH 01/45] add reconnecting-eventsource --- package.json | 1 + 1 file changed, 1 insertion(+) 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", From ee229387d7715ee9e78de618afd20a5f436d35af Mon Sep 17 00:00:00 2001 From: nflnkr Date: Mon, 20 Mar 2023 18:24:54 +0300 Subject: [PATCH 02/45] refactor, fix font --- src/theme.ts | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/src/theme.ts b/src/theme.ts index fb2194c..e53ecd1 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,14 @@ const theme = { } } ] - } + }, + MuiButtonBase: { + styleOverrides: { + root: { + fontFamily + } + }, + }, }, - - }; export default createTheme(deepmerge(paletteColor, theme)); \ No newline at end of file From c045ad315a216fad1c65057a6f02930fe471865a Mon Sep 17 00:00:00 2001 From: nflnkr Date: Mon, 20 Mar 2023 18:25:13 +0300 Subject: [PATCH 03/45] add support slug route --- src/index.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/index.tsx b/src/index.tsx index 6efa6e9..1eda719 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -18,7 +18,7 @@ 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"; const componentsArray = [ ["/users", ], @@ -26,7 +26,8 @@ const componentsArray = [ ["/tariffs", ], ["/discounts", ], ["/promocode", ], - ["/support", ] + ["/support", ], + ["/support/:ticketId", ], ] const container = document.getElementById('root'); From 88d696f53e2467ba8dc7d7d8ffb7ee0c9a5aaab8 Mon Sep 17 00:00:00 2001 From: nflnkr Date: Mon, 20 Mar 2023 18:25:28 +0300 Subject: [PATCH 04/45] add tickets store --- src/stores/mocks/tickets.ts | 49 +++++++++++++++++++++++++++++++++++++ src/stores/tickets.ts | 34 +++++++++++++++++++++++++ 2 files changed, 83 insertions(+) create mode 100644 src/stores/mocks/tickets.ts create mode 100644 src/stores/tickets.ts 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..f003498 --- /dev/null +++ b/src/stores/tickets.ts @@ -0,0 +1,34 @@ +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" + } + ) +); + +export const setTickets = (tickets: Ticket[]) => useTicketStore.setState(({ tickets })); +export const addTickets = (tickets: Ticket[]) => useTicketStore.setState(state => ({ tickets: [...state.tickets, ...tickets] })); + +export const addOrUpdateTicket = (updatedTicket: Ticket) => { + const state = useTicketStore.getState(); + const ticketIndex = state.tickets.findIndex(ticket => ticket.id === updatedTicket.id); + + if (ticketIndex === -1) { + return useTicketStore.setState({ tickets: [...state.tickets, updatedTicket] }); + } + + const newTickets = state.tickets.slice().splice(ticketIndex, 1, updatedTicket); + useTicketStore.setState({ tickets: newTickets }); +}; \ No newline at end of file From 30842c6412fa9d0225b4ea377448308d8fc8fcdb Mon Sep 17 00:00:00 2001 From: nflnkr Date: Mon, 20 Mar 2023 18:25:59 +0300 Subject: [PATCH 05/45] add ticket types --- src/model/ticket.ts | 66 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 src/model/ticket.ts diff --git a/src/model/ticket.ts b/src/model/ticket.ts new file mode 100644 index 0000000..df54f89 --- /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[]; +}; + +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[]; From 721c4125c632d16d9ba60022db429f2a7299ae44 Mon Sep 17 00:00:00 2001 From: nflnkr Date: Mon, 20 Mar 2023 18:26:47 +0300 Subject: [PATCH 06/45] add abort signal param --- src/kitUI/makeRequest.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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) { From 8e8cb3122f0099f6441c365d5f3706d75c6e2704 Mon Sep 17 00:00:00 2001 From: nflnkr Date: Mon, 20 Mar 2023 18:27:01 +0300 Subject: [PATCH 07/45] add SSE subscribe function --- src/api/tickets.ts | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 src/api/tickets.ts diff --git a/src/api/tickets.ts b/src/api/tickets.ts new file mode 100644 index 0000000..245068d --- /dev/null +++ b/src/api/tickets.ts @@ -0,0 +1,22 @@ +import ReconnectingEventSource from "reconnecting-eventsource"; + + +export function subscribeToAllTickets({ onMessage, onError, accessToken }: { + accessToken: string; + onMessage: (e: MessageEvent) => void; + onError: (e: Event) => void; +}) { + if (!accessToken) throw new Error("Trying to subscribe to SSE without access token"); + + const url = `https://admin.pena.digital/heruvym/subscribe?Authorization=${accessToken}`; + 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.close(); + }; +} \ No newline at end of file From 729ab964ddf25a5d0d06a74ffe609d31ce44cdf0 Mon Sep 17 00:00:00 2001 From: nflnkr Date: Mon, 20 Mar 2023 18:27:42 +0300 Subject: [PATCH 08/45] refactor, fetch tickets --- src/pages/dashboard/Content/Support/Chat.tsx | 16 + .../Content/Support/Pagination/index.tsx | 41 --- .../dashboard/Content/Support/Support.tsx | 142 ++++++++ .../dashboard/Content/Support/TicketItem.tsx | 76 +++++ src/pages/dashboard/Content/Support/index.tsx | 306 ------------------ 5 files changed, 234 insertions(+), 347 deletions(-) create mode 100644 src/pages/dashboard/Content/Support/Chat.tsx delete mode 100644 src/pages/dashboard/Content/Support/Pagination/index.tsx create mode 100644 src/pages/dashboard/Content/Support/Support.tsx create mode 100644 src/pages/dashboard/Content/Support/TicketItem.tsx delete mode 100644 src/pages/dashboard/Content/Support/index.tsx diff --git a/src/pages/dashboard/Content/Support/Chat.tsx b/src/pages/dashboard/Content/Support/Chat.tsx new file mode 100644 index 0000000..eaa5dad --- /dev/null +++ b/src/pages/dashboard/Content/Support/Chat.tsx @@ -0,0 +1,16 @@ +import { Box, useTheme } from "@mui/material"; + + +export default function Chat() { + const theme = useTheme() + + return ( + + ) +} \ No newline at end of file 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..34660e9 --- /dev/null +++ b/src/pages/dashboard/Content/Support/Support.tsx @@ -0,0 +1,142 @@ +import { useEffect } from "react"; +import { Box, Button, useTheme } from "@mui/material"; +import SearchOutlinedIcon from '@mui/icons-material/SearchOutlined'; +import HighlightOffOutlinedIcon from '@mui/icons-material/HighlightOffOutlined'; +import makeRequest from "@root/kitUI/makeRequest"; +import { enqueueSnackbar } from 'notistack'; +import { GetTicketsRequest, GetTicketsResponse, Ticket } from "@root/model/ticket"; +import { setTickets, addOrUpdateTicket, useTicketStore } from "@root/stores/tickets"; +import TicketItem from "./TicketItem"; +import { subscribeToAllTickets } from "@root/api/tickets"; +import Chat from "./Chat"; + + +export default function Support() { + const theme = useTheme(); + const tickets = useTicketStore(state => state.tickets); + + useEffect(function fetchTickets() { + const getTicketsBody: GetTicketsRequest = { + amt: 10, + page: 0, + status: "open", + }; + + const controller = new AbortController(); + + makeRequest({ + url: "https://admin.pena.digital/heruvym/getTickets", + method: "POST", + useToken: true, + body: getTicketsBody, + signal: controller.signal, + }).then(response => { + const result = (response as any).data as GetTicketsResponse; + console.log("GetTicketsResponse", result); + setTickets(result.data); + }).catch(error => { + console.log("Error fetching tickets", error); + enqueueSnackbar(error.message); + }); + + return () => controller.abort(); + }, []); + + useEffect(function subscribeToTickets() { + const token = localStorage.getItem("AT"); + if (!token) return; + + const unsubscribe = subscribeToAllTickets({ + accessToken: token, + onMessage(event) { + console.log("SSE received:", event.data); + try { + const newTicket = JSON.parse(event.data) as Ticket; + addOrUpdateTicket(newTicket); + } catch (error) { + console.log("Error parsing SSE", error); + } + }, + onError(event) { + console.log("SSE Error:", event); + } + }); + + return () => { + unsubscribe(); + }; + }, []); + + return ( + + + + + + + + + {tickets.map(ticket => + + )} + + + + ); +} \ No newline at end of file diff --git a/src/pages/dashboard/Content/Support/TicketItem.tsx b/src/pages/dashboard/Content/Support/TicketItem.tsx new file mode 100644 index 0000000..b18b8a8 --- /dev/null +++ b/src/pages/dashboard/Content/Support/TicketItem.tsx @@ -0,0 +1,76 @@ +import CircleIcon from '@mui/icons-material/Circle'; +import { Box, Card, CardActionArea, CardContent, useTheme } from "@mui/material"; +import { green } from '@mui/material/colors'; +import { Ticket } from "@root/model/ticket"; + + +const flexCenterSx = { + textAlign: "center", + display: "flex", + justifyContent: "center", + alignItems: "center", +}; + +interface Props { + isUnread?: boolean; + ticket: Ticket; +} + +export default function TicketItem({ isUnread, ticket }: Props) { + const theme = useTheme(); + + const unreadSx = { + border: "1px solid", + borderColor: theme.palette.golden.main, + backgroundColor: theme.palette.goldenMedium.main + }; + + return ( + + + + + {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/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 From f4371473536ab47d432994decbdb4fb85fd080f8 Mon Sep 17 00:00:00 2001 From: nflnkr Date: Tue, 21 Mar 2023 15:57:50 +0300 Subject: [PATCH 09/45] fix font breaking on route with slug --- src/fonts/GilroyRegular.woff | Bin 0 -> 35308 bytes src/index.css | 4 ++++ src/index.tsx | 2 ++ 3 files changed, 6 insertions(+) create mode 100644 src/fonts/GilroyRegular.woff create mode 100644 src/index.css diff --git a/src/fonts/GilroyRegular.woff b/src/fonts/GilroyRegular.woff new file mode 100644 index 0000000000000000000000000000000000000000..65da96343abbfc8a47ac90aff5b58b442d999bf9 GIT binary patch literal 35308 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], From f41ada299b25a7bc0ceeeccd2006ecf277709d4f Mon Sep 17 00:00:00 2001 From: nflnkr Date: Tue, 21 Mar 2023 15:58:06 +0300 Subject: [PATCH 10/45] add api functions --- src/api/tickets.ts | 72 ++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 69 insertions(+), 3 deletions(-) diff --git a/src/api/tickets.ts b/src/api/tickets.ts index 245068d..c9bbad8 100644 --- a/src/api/tickets.ts +++ b/src/api/tickets.ts @@ -1,14 +1,16 @@ +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; }) { - if (!accessToken) throw new Error("Trying to subscribe to SSE without access token"); - - const url = `https://admin.pena.digital/heruvym/subscribe?Authorization=${accessToken}`; + const url = `${supportApiUrl}/subscribe?Authorization=${accessToken}`; const eventSource = new ReconnectingEventSource(url); eventSource.addEventListener("open", () => console.log(`EventSource connected with ${url}`)); @@ -19,4 +21,68 @@ export function subscribeToAllTickets({ onMessage, onError, accessToken }: { 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 = 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.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; + console.log("GetTicketsResponse", result); + 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; + console.log("GetMessagesResponse", result); + return result; + }); +} + +export async function sendTicketMessage({ body }: { + body: SendTicketMessageRequest; +}) { + return makeRequest({ + url: `${supportApiUrl}/send`, + method: "POST", + useToken: true, + body, + }); } \ No newline at end of file From 36a7b4f10a9da1934d0fe8bf31f964d5ca365319 Mon Sep 17 00:00:00 2001 From: nflnkr Date: Tue, 21 Mar 2023 15:58:17 +0300 Subject: [PATCH 11/45] fix button font size --- src/theme.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/theme.ts b/src/theme.ts index e53ecd1..73879c8 100644 --- a/src/theme.ts +++ b/src/theme.ts @@ -215,7 +215,8 @@ const theme: ThemeOptions = { MuiButtonBase: { styleOverrides: { root: { - fontFamily + fontFamily, + fontSize: "16px", } }, }, From ff41ea51ff92a8b076bd0fe9e0011620ce987689 Mon Sep 17 00:00:00 2001 From: nflnkr Date: Tue, 21 Mar 2023 15:58:26 +0300 Subject: [PATCH 12/45] add example messages --- src/stores/mocks/messages.ts | 94 ++++++++++++++++++++++++++++++++++++ 1 file changed, 94 insertions(+) create mode 100644 src/stores/mocks/messages.ts 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 From 1c1f50926f63a8fa089a8902b5a3b4b7944b6365 Mon Sep 17 00:00:00 2001 From: nflnkr Date: Tue, 21 Mar 2023 15:58:40 +0300 Subject: [PATCH 13/45] add messages store --- src/stores/messages.ts | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 src/stores/messages.ts diff --git a/src/stores/messages.ts b/src/stores/messages.ts new file mode 100644 index 0000000..75ef7d0 --- /dev/null +++ b/src/stores/messages.ts @@ -0,0 +1,23 @@ +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" + } + ) +); + +export const setMessages = (messages: TicketMessage[]) => useMessageStore.setState(({ messages })); +export const addMessages = (messages: TicketMessage[]) => useMessageStore.setState(state => ({ messages: [...state.messages, ...messages] })); \ No newline at end of file From a6ea352180db3d073ec9290f45fce9f18ae8de8a Mon Sep 17 00:00:00 2001 From: nflnkr Date: Tue, 21 Mar 2023 15:59:46 +0300 Subject: [PATCH 14/45] refactor --- .../dashboard/Content/Support/Support.tsx | 22 +++++++------------ 1 file changed, 8 insertions(+), 14 deletions(-) diff --git a/src/pages/dashboard/Content/Support/Support.tsx b/src/pages/dashboard/Content/Support/Support.tsx index 34660e9..ec427bc 100644 --- a/src/pages/dashboard/Content/Support/Support.tsx +++ b/src/pages/dashboard/Content/Support/Support.tsx @@ -2,12 +2,11 @@ import { useEffect } from "react"; import { Box, Button, useTheme } from "@mui/material"; import SearchOutlinedIcon from '@mui/icons-material/SearchOutlined'; import HighlightOffOutlinedIcon from '@mui/icons-material/HighlightOffOutlined'; -import makeRequest from "@root/kitUI/makeRequest"; import { enqueueSnackbar } from 'notistack'; -import { GetTicketsRequest, GetTicketsResponse, Ticket } from "@root/model/ticket"; +import { GetTicketsRequest, Ticket } from "@root/model/ticket"; import { setTickets, addOrUpdateTicket, useTicketStore } from "@root/stores/tickets"; import TicketItem from "./TicketItem"; -import { subscribeToAllTickets } from "@root/api/tickets"; +import { getTickets, subscribeToAllTickets } from "@root/api/tickets"; import Chat from "./Chat"; @@ -21,17 +20,12 @@ export default function Support() { page: 0, status: "open", }; - const controller = new AbortController(); - makeRequest({ - url: "https://admin.pena.digital/heruvym/getTickets", - method: "POST", - useToken: true, + getTickets({ body: getTicketsBody, signal: controller.signal, - }).then(response => { - const result = (response as any).data as GetTicketsResponse; + }).then(result => { console.log("GetTicketsResponse", result); setTickets(result.data); }).catch(error => { @@ -49,12 +43,12 @@ export default function Support() { const unsubscribe = subscribeToAllTickets({ accessToken: token, onMessage(event) { - console.log("SSE received:", event.data); + console.log("SSE: ticket received:", event.data); try { const newTicket = JSON.parse(event.data) as Ticket; addOrUpdateTicket(newTicket); } catch (error) { - console.log("Error parsing SSE", error); + console.log("Error parsing ticket SSE", error); } }, onError(event) { @@ -71,12 +65,12 @@ export default function Support() { Date: Tue, 21 Mar 2023 16:00:04 +0300 Subject: [PATCH 15/45] add click handler --- src/pages/dashboard/Content/Support/TicketItem.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/pages/dashboard/Content/Support/TicketItem.tsx b/src/pages/dashboard/Content/Support/TicketItem.tsx index b18b8a8..49dfbc4 100644 --- a/src/pages/dashboard/Content/Support/TicketItem.tsx +++ b/src/pages/dashboard/Content/Support/TicketItem.tsx @@ -2,6 +2,7 @@ import CircleIcon from '@mui/icons-material/Circle'; import { Box, Card, CardActionArea, CardContent, useTheme } from "@mui/material"; import { green } from '@mui/material/colors'; import { Ticket } from "@root/model/ticket"; +import { useNavigate } from 'react-router-dom'; const flexCenterSx = { @@ -18,6 +19,7 @@ interface Props { export default function TicketItem({ isUnread, ticket }: Props) { const theme = useTheme(); + const navigate = useNavigate(); const unreadSx = { border: "1px solid", @@ -25,13 +27,17 @@ export default function TicketItem({ isUnread, ticket }: Props) { backgroundColor: theme.palette.goldenMedium.main }; + function handleCardClick() { + navigate(`/support/${ticket.id}`); + } + return ( - + Date: Tue, 21 Mar 2023 16:00:10 +0300 Subject: [PATCH 16/45] add chat --- src/pages/dashboard/Content/Support/Chat.tsx | 186 +++++++++++++++++- .../dashboard/Content/Support/Message.tsx | 45 +++++ 2 files changed, 225 insertions(+), 6 deletions(-) create mode 100644 src/pages/dashboard/Content/Support/Message.tsx diff --git a/src/pages/dashboard/Content/Support/Chat.tsx b/src/pages/dashboard/Content/Support/Chat.tsx index eaa5dad..e66e138 100644 --- a/src/pages/dashboard/Content/Support/Chat.tsx +++ b/src/pages/dashboard/Content/Support/Chat.tsx @@ -1,16 +1,190 @@ -import { Box, useTheme } from "@mui/material"; +import { Box, IconButton, InputAdornment, TextField, useTheme } from "@mui/material"; +import { addMessages, 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"; export default function Chat() { - const theme = useTheme() - + const theme = useTheme(); + const messages = useMessageStore(state => state.messages); + const [messageField, setMessageField] = useState(""); + const ticketId = useParams().ticketId; + const chatBoxRef = useRef(null); + + useEffect(function scrollOnNewMessage() { + scrollToBottom(); + }, [messages]); + + useEffect(function fetchTicketMessages() { + if (!ticketId) return; + + const getTicketsBody: GetMessagesRequest = { + amt: 10, + page: 0, + srch: "", + ticket: ticketId, + }; + const controller = new AbortController(); + + getTicketMessages({ + body: getTicketsBody, + signal: controller.signal, + }).then(result => { + console.log("GetTicketsResponse", result); + setMessages(result); + }).catch(error => { + console.log("Error fetching tickets", error); + enqueueSnackbar(error.message); + }); + + return () => controller.abort(); + }, [ticketId]); + + useEffect(function subscribeToMessages() { + if (!ticketId) return; + + const token = localStorage.getItem("AT"); + if (!token) return; + + const unsubscribe = subscribeToTicketMessages({ + ticketId, + accessToken: token, + onMessage(event) { + console.log("SSE: message received:", event.data); + if (event.data === "no tickets 4 user") return; + + try { + const newMessage = JSON.parse(event.data) as TicketMessage; + addMessages([newMessage]); + } catch (error) { + 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 (!ticketId) return; + + sendTicketMessage({ + body: { + files: [], + lang: "ru", + message: messageField, + ticket: ticketId, + } + }); + setMessageField(""); + } + + function handleAddAttachment() { + + } + + function handleTextfieldKeyPress(e: KeyboardEvent) { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + handleSendMessage(); + } + } + return ( - ) + borderRadius: "3px", + p: "8px", + display: "flex", + flexDirection: "column", + gap: "8px", + }}> + + {messages.map((message, index) => + + )} + + 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, + } + }} + /> + + ); } \ No newline at end of file diff --git a/src/pages/dashboard/Content/Support/Message.tsx b/src/pages/dashboard/Content/Support/Message.tsx new file mode 100644 index 0000000..e08b357 --- /dev/null +++ b/src/pages/dashboard/Content/Support/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 From 3504ea10da2a0fa92734daf0610207dfa89edca3 Mon Sep 17 00:00:00 2001 From: nflnkr Date: Tue, 21 Mar 2023 17:05:52 +0300 Subject: [PATCH 17/45] fix log type --- src/pages/dashboard/Content/Support/Chat.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/dashboard/Content/Support/Chat.tsx b/src/pages/dashboard/Content/Support/Chat.tsx index e66e138..788fbb8 100644 --- a/src/pages/dashboard/Content/Support/Chat.tsx +++ b/src/pages/dashboard/Content/Support/Chat.tsx @@ -36,7 +36,7 @@ export default function Chat() { body: getTicketsBody, signal: controller.signal, }).then(result => { - console.log("GetTicketsResponse", result); + console.log("GetMessagesResponse", result); setMessages(result); }).catch(error => { console.log("Error fetching tickets", error); From 932378411448a1bb0a86d78487a1a5a62f8e43e9 Mon Sep 17 00:00:00 2001 From: nflnkr Date: Tue, 21 Mar 2023 17:08:21 +0300 Subject: [PATCH 18/45] minor refactor --- src/api/tickets.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/api/tickets.ts b/src/api/tickets.ts index c9bbad8..e17eff3 100644 --- a/src/api/tickets.ts +++ b/src/api/tickets.ts @@ -54,7 +54,6 @@ export async function getTickets({ body, signal }: { signal, }).then(response => { const result = (response as any).data as GetTicketsResponse; - console.log("GetTicketsResponse", result); return result; }); } @@ -71,7 +70,6 @@ export async function getTicketMessages({ body, signal }: { signal, }).then(response => { const result = (response as any).data as GetMessagesResponse; - console.log("GetMessagesResponse", result); return result; }); } From b360bf9757c2ce7c0a28298a7eeeb0a334cd8015 Mon Sep 17 00:00:00 2001 From: nflnkr Date: Tue, 21 Mar 2023 17:43:08 +0300 Subject: [PATCH 19/45] fix self messages --- src/pages/dashboard/Content/Support/Chat.tsx | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/src/pages/dashboard/Content/Support/Chat.tsx b/src/pages/dashboard/Content/Support/Chat.tsx index 788fbb8..c774c4b 100644 --- a/src/pages/dashboard/Content/Support/Chat.tsx +++ b/src/pages/dashboard/Content/Support/Chat.tsx @@ -8,15 +8,19 @@ 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 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]); @@ -32,6 +36,7 @@ export default function Chat() { }; const controller = new AbortController(); + setMessages([]); getTicketMessages({ body: getTicketsBody, signal: controller.signal, @@ -58,7 +63,7 @@ export default function Chat() { onMessage(event) { console.log("SSE: message received:", event.data); if (event.data === "no tickets 4 user") return; - + try { const newMessage = JSON.parse(event.data) as TicketMessage; addMessages([newMessage]); @@ -67,7 +72,7 @@ export default function Chat() { } }, onError(event) { - console.log("SSE Error:", event); + console.log("SSE Error:", event); }, }); @@ -136,8 +141,8 @@ export default function Chat() { colorScheme: "dark", }} > - {messages.map((message, index) => - + {ticket && messages.map((message, index) => + )} Date: Tue, 21 Mar 2023 17:43:21 +0300 Subject: [PATCH 20/45] add tickets sorting --- .../dashboard/Content/Support/Support.tsx | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/src/pages/dashboard/Content/Support/Support.tsx b/src/pages/dashboard/Content/Support/Support.tsx index ec427bc..6fd6e48 100644 --- a/src/pages/dashboard/Content/Support/Support.tsx +++ b/src/pages/dashboard/Content/Support/Support.tsx @@ -22,6 +22,7 @@ export default function Support() { }; const controller = new AbortController(); + setTickets([]); getTickets({ body: getTicketsBody, signal: controller.signal, @@ -61,6 +62,8 @@ export default function Support() { }; }, []); + const sortedTickets = tickets.sort(sortTicketsByUpdateTime).sort(sortTicketsByUnread); + return ( - {tickets.map(ticket => - + {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 From 88b2a25b0070d08b8bcd1c564d2aa6d9aa29a233 Mon Sep 17 00:00:00 2001 From: nflnkr Date: Wed, 22 Mar 2023 12:22:45 +0300 Subject: [PATCH 21/45] remove useless action --- src/stores/tickets.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/stores/tickets.ts b/src/stores/tickets.ts index f003498..0e110e4 100644 --- a/src/stores/tickets.ts +++ b/src/stores/tickets.ts @@ -19,7 +19,6 @@ export const useTicketStore = create()( ); export const setTickets = (tickets: Ticket[]) => useTicketStore.setState(({ tickets })); -export const addTickets = (tickets: Ticket[]) => useTicketStore.setState(state => ({ tickets: [...state.tickets, ...tickets] })); export const addOrUpdateTicket = (updatedTicket: Ticket) => { const state = useTicketStore.getState(); From 8b656fc3500745141dbb24f7768348c8cbb42d76 Mon Sep 17 00:00:00 2001 From: nflnkr Date: Wed, 22 Mar 2023 12:24:12 +0300 Subject: [PATCH 22/45] fix adding messages --- src/pages/dashboard/Content/Support/Chat.tsx | 4 ++-- src/stores/messages.ts | 13 ++++++++++++- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/src/pages/dashboard/Content/Support/Chat.tsx b/src/pages/dashboard/Content/Support/Chat.tsx index c774c4b..5a09cea 100644 --- a/src/pages/dashboard/Content/Support/Chat.tsx +++ b/src/pages/dashboard/Content/Support/Chat.tsx @@ -1,5 +1,5 @@ import { Box, IconButton, InputAdornment, TextField, useTheme } from "@mui/material"; -import { addMessages, setMessages, useMessageStore } from "@root/stores/messages"; +import { addOrUpdateMessage, setMessages, useMessageStore } from "@root/stores/messages"; import Message from "./Message"; import SendIcon from "@mui/icons-material/Send"; import AttachFileIcon from "@mui/icons-material/AttachFile"; @@ -66,7 +66,7 @@ export default function Chat() { try { const newMessage = JSON.parse(event.data) as TicketMessage; - addMessages([newMessage]); + addOrUpdateMessage(newMessage); } catch (error) { console.log("Error parsing message SSE", error); } diff --git a/src/stores/messages.ts b/src/stores/messages.ts index 75ef7d0..cb2fcce 100644 --- a/src/stores/messages.ts +++ b/src/stores/messages.ts @@ -20,4 +20,15 @@ export const useMessageStore = create()( ); export const setMessages = (messages: TicketMessage[]) => useMessageStore.setState(({ messages })); -export const addMessages = (messages: TicketMessage[]) => useMessageStore.setState(state => ({ messages: [...state.messages, ...messages] })); \ No newline at end of file + +export const addOrUpdateMessage = (newMessage: TicketMessage) => { + const state = useMessageStore.getState(); + const ticketIndex = state.messages.findIndex(message => message.id === newMessage.id); + + if (ticketIndex === -1) { + return useMessageStore.setState({ messages: [...state.messages, newMessage] }); + } + + const newMessages = state.messages.slice().splice(ticketIndex, 1, newMessage); + useMessageStore.setState({ messages: newMessages }); +}; \ No newline at end of file From d0789be780a358c5a5696a3d10af3b8754b56fe2 Mon Sep 17 00:00:00 2001 From: nflnkr Date: Wed, 22 Mar 2023 12:24:51 +0300 Subject: [PATCH 23/45] add message sorting --- src/pages/dashboard/Content/Support/Chat.tsx | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/pages/dashboard/Content/Support/Chat.tsx b/src/pages/dashboard/Content/Support/Chat.tsx index 5a09cea..89d0e28 100644 --- a/src/pages/dashboard/Content/Support/Chat.tsx +++ b/src/pages/dashboard/Content/Support/Chat.tsx @@ -1,5 +1,5 @@ import { Box, IconButton, InputAdornment, TextField, useTheme } from "@mui/material"; -import { addOrUpdateMessage, setMessages, useMessageStore } from "@root/stores/messages"; +import { addOrUpdateMessage, setMessages, useMessageStore } from "@root/stores/messages"; import Message from "./Message"; import SendIcon from "@mui/icons-material/Send"; import AttachFileIcon from "@mui/icons-material/AttachFile"; @@ -116,6 +116,8 @@ export default function Chat() { } } + const sortedMessages = messages.sort(sortMessagesByTime); + return ( - {ticket && messages.map((message, index) => + {ticket && sortedMessages.map(message => )} @@ -192,4 +194,10 @@ export default function Chat() { /> ); +} + +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 From 0e6970cc76c3b5b1dd23a2e798234ce5437fb737 Mon Sep 17 00:00:00 2001 From: nflnkr Date: Wed, 22 Mar 2023 12:26:41 +0300 Subject: [PATCH 24/45] fix typo --- src/stores/messages.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/stores/messages.ts b/src/stores/messages.ts index cb2fcce..4362d2c 100644 --- a/src/stores/messages.ts +++ b/src/stores/messages.ts @@ -23,12 +23,12 @@ export const setMessages = (messages: TicketMessage[]) => useMessageStore.setSta export const addOrUpdateMessage = (newMessage: TicketMessage) => { const state = useMessageStore.getState(); - const ticketIndex = state.messages.findIndex(message => message.id === newMessage.id); + const messageIndex = state.messages.findIndex(message => message.id === newMessage.id); - if (ticketIndex === -1) { + if (messageIndex === -1) { return useMessageStore.setState({ messages: [...state.messages, newMessage] }); } - const newMessages = state.messages.slice().splice(ticketIndex, 1, newMessage); + const newMessages = state.messages.slice().splice(messageIndex, 1, newMessage); useMessageStore.setState({ messages: newMessages }); }; \ No newline at end of file From c4f2139bcb354f5dd5e2b7b3987de14ac834178e Mon Sep 17 00:00:00 2001 From: nflnkr Date: Wed, 22 Mar 2023 12:33:17 +0300 Subject: [PATCH 25/45] extract ticket list component --- .../dashboard/Content/Support/Support.tsx | 33 ++------------- .../dashboard/Content/Support/TicketList.tsx | 40 +++++++++++++++++++ 2 files changed, 43 insertions(+), 30 deletions(-) create mode 100644 src/pages/dashboard/Content/Support/TicketList.tsx diff --git a/src/pages/dashboard/Content/Support/Support.tsx b/src/pages/dashboard/Content/Support/Support.tsx index 6fd6e48..3917ff6 100644 --- a/src/pages/dashboard/Content/Support/Support.tsx +++ b/src/pages/dashboard/Content/Support/Support.tsx @@ -4,15 +4,14 @@ import SearchOutlinedIcon from '@mui/icons-material/SearchOutlined'; import HighlightOffOutlinedIcon from '@mui/icons-material/HighlightOffOutlined'; import { enqueueSnackbar } from 'notistack'; import { GetTicketsRequest, Ticket } from "@root/model/ticket"; -import { setTickets, addOrUpdateTicket, useTicketStore } from "@root/stores/tickets"; -import TicketItem from "./TicketItem"; +import { setTickets, addOrUpdateTicket } from "@root/stores/tickets"; import { getTickets, subscribeToAllTickets } from "@root/api/tickets"; import Chat from "./Chat"; +import TicketList from "./TicketList"; export default function Support() { const theme = useTheme(); - const tickets = useTicketStore(state => state.tickets); useEffect(function fetchTickets() { const getTicketsBody: GetTicketsRequest = { @@ -62,8 +61,6 @@ export default function Support() { }; }, []); - 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/TicketList.tsx b/src/pages/dashboard/Content/Support/TicketList.tsx new file mode 100644 index 0000000..dc6dc3d --- /dev/null +++ b/src/pages/dashboard/Content/Support/TicketList.tsx @@ -0,0 +1,40 @@ +import { Box, useTheme } from "@mui/material"; +import { Ticket } from "@root/model/ticket"; +import { useTicketStore } from "@root/stores/tickets"; +import TicketItem from "./TicketItem"; + + +export default function TicketList() { + const theme = useTheme() + const tickets = useTicketStore(state => state.tickets); + + 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 From 29392562d5e38b6d8e9b35dc7ed1d2b842315d67 Mon Sep 17 00:00:00 2001 From: nflnkr Date: Wed, 22 Mar 2023 14:41:43 +0300 Subject: [PATCH 26/45] refactor messages update --- src/pages/dashboard/Content/Support/Chat.tsx | 4 ++-- src/stores/messages.ts | 11 ++++------- 2 files changed, 6 insertions(+), 9 deletions(-) diff --git a/src/pages/dashboard/Content/Support/Chat.tsx b/src/pages/dashboard/Content/Support/Chat.tsx index 89d0e28..7e3ccb1 100644 --- a/src/pages/dashboard/Content/Support/Chat.tsx +++ b/src/pages/dashboard/Content/Support/Chat.tsx @@ -29,7 +29,7 @@ export default function Chat() { if (!ticketId) return; const getTicketsBody: GetMessagesRequest = { - amt: 10, + amt: 100, // TODO use pagination page: 0, srch: "", ticket: ticketId, @@ -66,7 +66,7 @@ export default function Chat() { try { const newMessage = JSON.parse(event.data) as TicketMessage; - addOrUpdateMessage(newMessage); + addOrUpdateMessage([newMessage]); } catch (error) { console.log("Error parsing message SSE", error); } diff --git a/src/stores/messages.ts b/src/stores/messages.ts index 4362d2c..6ba13f4 100644 --- a/src/stores/messages.ts +++ b/src/stores/messages.ts @@ -21,14 +21,11 @@ export const useMessageStore = create()( export const setMessages = (messages: TicketMessage[]) => useMessageStore.setState(({ messages })); -export const addOrUpdateMessage = (newMessage: TicketMessage) => { +export const addOrUpdateMessage = (receivedMessages: TicketMessage[]) => { const state = useMessageStore.getState(); - const messageIndex = state.messages.findIndex(message => message.id === newMessage.id); + const messageIdToMessageMap: { [messageId: string]: TicketMessage; } = {}; - if (messageIndex === -1) { - return useMessageStore.setState({ messages: [...state.messages, newMessage] }); - } + [...state.messages, ...receivedMessages].forEach(message => messageIdToMessageMap[message.id] = message); - const newMessages = state.messages.slice().splice(messageIndex, 1, newMessage); - useMessageStore.setState({ messages: newMessages }); + useMessageStore.setState({ messages: Object.values(messageIdToMessageMap) }); }; \ No newline at end of file From c3dee2f79af0fe05791e4c7f722979fbc8498489 Mon Sep 17 00:00:00 2001 From: nflnkr Date: Wed, 22 Mar 2023 14:42:12 +0300 Subject: [PATCH 27/45] fix type --- src/model/ticket.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/model/ticket.ts b/src/model/ticket.ts index df54f89..2268f48 100644 --- a/src/model/ticket.ts +++ b/src/model/ticket.ts @@ -28,7 +28,7 @@ export interface GetTicketsRequest { export interface GetTicketsResponse { count: number; - data: Ticket[]; + data: Ticket[] | null; }; export interface Ticket { From ea9fd4f0c32a39372a32bc767682799fa681edbb Mon Sep 17 00:00:00 2001 From: nflnkr Date: Wed, 22 Mar 2023 14:43:19 +0300 Subject: [PATCH 28/45] fix tickets update action --- src/stores/tickets.ts | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/src/stores/tickets.ts b/src/stores/tickets.ts index 0e110e4..9b9e558 100644 --- a/src/stores/tickets.ts +++ b/src/stores/tickets.ts @@ -18,16 +18,11 @@ export const useTicketStore = create()( ) ); -export const setTickets = (tickets: Ticket[]) => useTicketStore.setState(({ tickets })); - -export const addOrUpdateTicket = (updatedTicket: Ticket) => { +export const updateTickets = (receivedTickets: Ticket[]) => { const state = useTicketStore.getState(); - const ticketIndex = state.tickets.findIndex(ticket => ticket.id === updatedTicket.id); + const ticketIdToTicketMap: { [ticketId: string]: Ticket; } = {}; - if (ticketIndex === -1) { - return useTicketStore.setState({ tickets: [...state.tickets, updatedTicket] }); - } + [...state.tickets, ...receivedTickets].forEach(ticket => ticketIdToTicketMap[ticket.id] = ticket); - const newTickets = state.tickets.slice().splice(ticketIndex, 1, updatedTicket); - useTicketStore.setState({ tickets: newTickets }); + useTicketStore.setState({ tickets: Object.values(ticketIdToTicketMap) }); }; \ No newline at end of file From b95da6d844a0b7a40ccb886c0f515d3f8c6771ce Mon Sep 17 00:00:00 2001 From: nflnkr Date: Wed, 22 Mar 2023 14:43:43 +0300 Subject: [PATCH 29/45] add fetch on scroll --- .../dashboard/Content/Support/Support.tsx | 109 +----------- .../dashboard/Content/Support/TicketList.tsx | 168 ++++++++++++++++-- src/utils/throttle.ts | 31 ++++ 3 files changed, 185 insertions(+), 123 deletions(-) create mode 100644 src/utils/throttle.ts diff --git a/src/pages/dashboard/Content/Support/Support.tsx b/src/pages/dashboard/Content/Support/Support.tsx index 3917ff6..62b4f07 100644 --- a/src/pages/dashboard/Content/Support/Support.tsx +++ b/src/pages/dashboard/Content/Support/Support.tsx @@ -1,65 +1,9 @@ -import { useEffect } from "react"; -import { Box, Button, useTheme } from "@mui/material"; -import SearchOutlinedIcon from '@mui/icons-material/SearchOutlined'; -import HighlightOffOutlinedIcon from '@mui/icons-material/HighlightOffOutlined'; -import { enqueueSnackbar } from 'notistack'; -import { GetTicketsRequest, Ticket } from "@root/model/ticket"; -import { setTickets, addOrUpdateTicket } from "@root/stores/tickets"; -import { getTickets, subscribeToAllTickets } from "@root/api/tickets"; +import { Box } from "@mui/material"; import Chat from "./Chat"; import TicketList from "./TicketList"; export default function Support() { - const theme = useTheme(); - - useEffect(function fetchTickets() { - const getTicketsBody: GetTicketsRequest = { - amt: 10, - page: 0, - status: "open", - }; - const controller = new AbortController(); - - setTickets([]); - getTickets({ - body: getTicketsBody, - signal: controller.signal, - }).then(result => { - console.log("GetTicketsResponse", result); - setTickets(result.data); - }).catch(error => { - console.log("Error fetching tickets", error); - enqueueSnackbar(error.message); - }); - - return () => controller.abort(); - }, []); - - useEffect(function subscribeToTickets() { - const token = localStorage.getItem("AT"); - if (!token) return; - - const unsubscribe = subscribeToAllTickets({ - accessToken: token, - onMessage(event) { - console.log("SSE: ticket received:", event.data); - try { - const newTicket = JSON.parse(event.data) as Ticket; - addOrUpdateTicket(newTicket); - } catch (error) { - console.log("Error parsing ticket SSE", error); - } - }, - onError(event) { - console.log("SSE Error:", event); - } - }); - - return () => { - unsubscribe(); - }; - }, []); return ( - - - - - - - + ); } \ No newline at end of file diff --git a/src/pages/dashboard/Content/Support/TicketList.tsx b/src/pages/dashboard/Content/Support/TicketList.tsx index dc6dc3d..979b33c 100644 --- a/src/pages/dashboard/Content/Support/TicketList.tsx +++ b/src/pages/dashboard/Content/Support/TicketList.tsx @@ -1,30 +1,166 @@ -import { Box, useTheme } from "@mui/material"; -import { Ticket } from "@root/model/ticket"; -import { useTicketStore } from "@root/stores/tickets"; +import HighlightOffOutlinedIcon from '@mui/icons-material/HighlightOffOutlined'; +import SearchOutlinedIcon from '@mui/icons-material/SearchOutlined'; +import { Box, Button, useTheme } from "@mui/material"; +import { getTickets, subscribeToAllTickets } from "@root/api/tickets"; +import { GetTicketsRequest, Ticket } from "@root/model/ticket"; +import { updateTickets, useTicketStore } from "@root/stores/tickets"; +import { throttle } from '@root/utils/throttle'; +import { enqueueSnackbar } from "notistack"; +import { useEffect, useRef, useState } from "react"; import TicketItem from "./TicketItem"; +type TicketsFetchState = "idle" | "fetching" | "all fetched"; + +const TICKETS_PER_PAGE = 20; + export default function TicketList() { - const theme = useTheme() + const theme = useTheme(); const tickets = useTicketStore(state => state.tickets); + const [currentPage, setCurrentPage] = useState(0); + const ticketsBoxRef = useRef(null); + const isFetchingTicketsRef = useRef("idle"); + + useEffect(function fetchTickets() { + const getTicketsBody: GetTicketsRequest = { + amt: TICKETS_PER_PAGE, + page: currentPage, + status: "open", + }; + const controller = new AbortController(); + + isFetchingTicketsRef.current = "fetching"; + getTickets({ + body: getTicketsBody, + signal: controller.signal, + }).then(result => { + console.log("GetTicketsResponse", result); + if (result.data) { + updateTickets(result.data); + isFetchingTicketsRef.current = "idle"; + } else isFetchingTicketsRef.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) { + console.log("SSE: ticket received:", event.data); + try { + const newTicket = JSON.parse(event.data) as Ticket; + updateTickets([newTicket]); + } catch (error) { + console.log("Error parsing ticket SSE", error); + } + }, + onError(event) { + console.log("SSE Error:", event); + } + }); + + return () => { + unsubscribe(); + }; + }, []); + + useEffect(function updateCurrentPageOnScroll() { + if (!ticketsBoxRef.current) return; + + const ticketsBox = ticketsBoxRef.current; + const scrollHandler = () => { + const scrollBottom = ticketsBox.scrollHeight - ticketsBox.scrollTop - ticketsBox.clientHeight; + if ( + scrollBottom < ticketsBox.scrollHeight * 0.5 && + isFetchingTicketsRef.current !== "all fetched" + ) setCurrentPage(prev => prev + 1); + }; + + const throttledScrollHandler = throttle(scrollHandler, 200); + ticketsBox.addEventListener("scroll", throttledScrollHandler); + + return () => { + ticketsBox.removeEventListener("scroll", throttledScrollHandler); + }; + }, []); const sortedTickets = tickets.sort(sortTicketsByUpdateTime).sort(sortTicketsByUnread); - + return ( - {sortedTickets.map(ticket => - - )} + + + + + + {sortedTickets.map(ticket => + + )} + - ) + ); } function sortTicketsByUpdateTime(ticket1: Ticket, ticket2: Ticket) { 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 From 8644e9af503c43c30f0b135469fe4c710ce026e9 Mon Sep 17 00:00:00 2001 From: nflnkr Date: Thu, 23 Mar 2023 14:31:21 +0300 Subject: [PATCH 30/45] fix style --- src/pages/dashboard/Content/Support/Chat.tsx | 7 +++--- .../dashboard/Content/Support/TicketItem.tsx | 25 ++++--------------- .../dashboard/Content/Support/TicketList.tsx | 7 ++++-- 3 files changed, 14 insertions(+), 25 deletions(-) diff --git a/src/pages/dashboard/Content/Support/Chat.tsx b/src/pages/dashboard/Content/Support/Chat.tsx index 7e3ccb1..7576741 100644 --- a/src/pages/dashboard/Content/Support/Chat.tsx +++ b/src/pages/dashboard/Content/Support/Chat.tsx @@ -1,4 +1,4 @@ -import { Box, IconButton, InputAdornment, TextField, useTheme } from "@mui/material"; +import { Box, IconButton, InputAdornment, TextField, useMediaQuery, useTheme } from "@mui/material"; import { addOrUpdateMessage, setMessages, useMessageStore } from "@root/stores/messages"; import Message from "./Message"; import SendIcon from "@mui/icons-material/Send"; @@ -13,6 +13,7 @@ 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(""); @@ -122,11 +123,11 @@ export default function Chat() { diff --git a/src/pages/dashboard/Content/Support/TicketItem.tsx b/src/pages/dashboard/Content/Support/TicketItem.tsx index 49dfbc4..08c6b74 100644 --- a/src/pages/dashboard/Content/Support/TicketItem.tsx +++ b/src/pages/dashboard/Content/Support/TicketItem.tsx @@ -10,6 +10,7 @@ const flexCenterSx = { display: "flex", justifyContent: "center", alignItems: "center", + padding: "10px", }; interface Props { @@ -44,35 +45,19 @@ export default function TicketItem({ isUnread, ticket }: Props) { backgroundColor: "transparent", ...(isUnread && unreadSx), }}> - + {new Date(ticket.top_message.created_at).toLocaleDateString()} - + {ticket.top_message.message} - + - + ИНФО diff --git a/src/pages/dashboard/Content/Support/TicketList.tsx b/src/pages/dashboard/Content/Support/TicketList.tsx index 979b33c..e20f5c2 100644 --- a/src/pages/dashboard/Content/Support/TicketList.tsx +++ b/src/pages/dashboard/Content/Support/TicketList.tsx @@ -1,6 +1,6 @@ import HighlightOffOutlinedIcon from '@mui/icons-material/HighlightOffOutlined'; import SearchOutlinedIcon from '@mui/icons-material/SearchOutlined'; -import { Box, Button, useTheme } from "@mui/material"; +import { Box, Button, useMediaQuery, useTheme } from "@mui/material"; import { getTickets, subscribeToAllTickets } from "@root/api/tickets"; import { GetTicketsRequest, Ticket } from "@root/model/ticket"; import { updateTickets, useTicketStore } from "@root/stores/tickets"; @@ -16,6 +16,7 @@ const TICKETS_PER_PAGE = 20; export default function TicketList() { const theme = useTheme(); + const upMd = useMediaQuery(theme.breakpoints.up("md")); const tickets = useTicketStore(state => state.tickets); const [currentPage, setCurrentPage] = useState(0); const ticketsBoxRef = useRef(null); @@ -96,8 +97,10 @@ export default function TicketList() { return ( From 44d46a51cd7513ecaa49125a575db4f5d921db9c Mon Sep 17 00:00:00 2001 From: nflnkr Date: Thu, 23 Mar 2023 14:32:09 +0300 Subject: [PATCH 31/45] add ticket list collapse --- .../dashboard/Content/Support/Collapse.tsx | 53 +++++++++++++++++++ .../dashboard/Content/Support/ExpandIcon.tsx | 17 ++++++ .../dashboard/Content/Support/Support.tsx | 18 +++++-- 3 files changed, 83 insertions(+), 5 deletions(-) create mode 100644 src/pages/dashboard/Content/Support/Collapse.tsx create mode 100644 src/pages/dashboard/Content/Support/ExpandIcon.tsx 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/Support.tsx b/src/pages/dashboard/Content/Support/Support.tsx index 62b4f07..fc827d6 100644 --- a/src/pages/dashboard/Content/Support/Support.tsx +++ b/src/pages/dashboard/Content/Support/Support.tsx @@ -1,19 +1,27 @@ -import { Box } from "@mui/material"; +import { Box, useMediaQuery, useTheme } from "@mui/material"; import Chat from "./Chat"; +import Collapse from "./Collapse"; import TicketList from "./TicketList"; export default function Support() { + const theme = useTheme(); + const upMd = useMediaQuery(theme.breakpoints.up("md")); return ( + {!upMd && + + + + } - + {upMd && } ); } \ No newline at end of file From 2552696acabac1eb4c374294edff60c3b6b02a19 Mon Sep 17 00:00:00 2001 From: nflnkr Date: Thu, 23 Mar 2023 14:40:19 +0300 Subject: [PATCH 32/45] fix page change when fetching --- src/pages/dashboard/Content/Support/TicketList.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/pages/dashboard/Content/Support/TicketList.tsx b/src/pages/dashboard/Content/Support/TicketList.tsx index e20f5c2..e968d60 100644 --- a/src/pages/dashboard/Content/Support/TicketList.tsx +++ b/src/pages/dashboard/Content/Support/TicketList.tsx @@ -81,7 +81,7 @@ export default function TicketList() { const scrollBottom = ticketsBox.scrollHeight - ticketsBox.scrollTop - ticketsBox.clientHeight; if ( scrollBottom < ticketsBox.scrollHeight * 0.5 && - isFetchingTicketsRef.current !== "all fetched" + isFetchingTicketsRef.current === "idle" ) setCurrentPage(prev => prev + 1); }; @@ -93,6 +93,10 @@ export default function TicketList() { }; }, []); + useEffect(() => { + console.log("currentPage:", currentPage); + }, [currentPage]); + const sortedTickets = tickets.sort(sortTicketsByUpdateTime).sort(sortTicketsByUnread); return ( @@ -156,6 +160,7 @@ export default function TicketList() { overflow: "auto", overflowY: "auto", padding: "10px", + height: "100px", }} > {sortedTickets.map(ticket => From f567dcf4ec31924a1bc7472fa6cc473d4f52e5af Mon Sep 17 00:00:00 2001 From: nflnkr Date: Thu, 23 Mar 2023 14:47:00 +0300 Subject: [PATCH 33/45] fix height --- src/pages/dashboard/Content/Support/TicketList.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/pages/dashboard/Content/Support/TicketList.tsx b/src/pages/dashboard/Content/Support/TicketList.tsx index e968d60..fca77cd 100644 --- a/src/pages/dashboard/Content/Support/TicketList.tsx +++ b/src/pages/dashboard/Content/Support/TicketList.tsx @@ -160,7 +160,6 @@ export default function TicketList() { overflow: "auto", overflowY: "auto", padding: "10px", - height: "100px", }} > {sortedTickets.map(ticket => From e553f697e5304df8048e7314ac7593aa04ca80b3 Mon Sep 17 00:00:00 2001 From: nflnkr Date: Wed, 29 Mar 2023 13:31:02 +0300 Subject: [PATCH 34/45] refactor folder structure --- src/pages/dashboard/Content/Support/{ => Chat}/Chat.tsx | 0 src/pages/dashboard/Content/Support/{ => Chat}/Message.tsx | 0 src/pages/dashboard/Content/Support/Support.tsx | 4 ++-- .../dashboard/Content/Support/{ => TicketList}/TicketItem.tsx | 0 .../dashboard/Content/Support/{ => TicketList}/TicketList.tsx | 0 5 files changed, 2 insertions(+), 2 deletions(-) rename src/pages/dashboard/Content/Support/{ => Chat}/Chat.tsx (100%) rename src/pages/dashboard/Content/Support/{ => Chat}/Message.tsx (100%) rename src/pages/dashboard/Content/Support/{ => TicketList}/TicketItem.tsx (100%) rename src/pages/dashboard/Content/Support/{ => TicketList}/TicketList.tsx (100%) diff --git a/src/pages/dashboard/Content/Support/Chat.tsx b/src/pages/dashboard/Content/Support/Chat/Chat.tsx similarity index 100% rename from src/pages/dashboard/Content/Support/Chat.tsx rename to src/pages/dashboard/Content/Support/Chat/Chat.tsx diff --git a/src/pages/dashboard/Content/Support/Message.tsx b/src/pages/dashboard/Content/Support/Chat/Message.tsx similarity index 100% rename from src/pages/dashboard/Content/Support/Message.tsx rename to src/pages/dashboard/Content/Support/Chat/Message.tsx diff --git a/src/pages/dashboard/Content/Support/Support.tsx b/src/pages/dashboard/Content/Support/Support.tsx index fc827d6..d7d9d75 100644 --- a/src/pages/dashboard/Content/Support/Support.tsx +++ b/src/pages/dashboard/Content/Support/Support.tsx @@ -1,7 +1,7 @@ import { Box, useMediaQuery, useTheme } from "@mui/material"; -import Chat from "./Chat"; +import Chat from "./Chat/Chat"; import Collapse from "./Collapse"; -import TicketList from "./TicketList"; +import TicketList from "./TicketList/TicketList"; export default function Support() { diff --git a/src/pages/dashboard/Content/Support/TicketItem.tsx b/src/pages/dashboard/Content/Support/TicketList/TicketItem.tsx similarity index 100% rename from src/pages/dashboard/Content/Support/TicketItem.tsx rename to src/pages/dashboard/Content/Support/TicketList/TicketItem.tsx diff --git a/src/pages/dashboard/Content/Support/TicketList.tsx b/src/pages/dashboard/Content/Support/TicketList/TicketList.tsx similarity index 100% rename from src/pages/dashboard/Content/Support/TicketList.tsx rename to src/pages/dashboard/Content/Support/TicketList/TicketList.tsx From 2f132c814d349760984593d0bdbcad27c12a8301 Mon Sep 17 00:00:00 2001 From: nflnkr Date: Wed, 29 Mar 2023 13:57:33 +0300 Subject: [PATCH 35/45] fix tickets refetching --- .../dashboard/Content/Support/Support.tsx | 68 ++++++++++++++- .../Content/Support/TicketList/TicketList.tsx | 82 +++---------------- 2 files changed, 78 insertions(+), 72 deletions(-) diff --git a/src/pages/dashboard/Content/Support/Support.tsx b/src/pages/dashboard/Content/Support/Support.tsx index d7d9d75..d07a691 100644 --- a/src/pages/dashboard/Content/Support/Support.tsx +++ b/src/pages/dashboard/Content/Support/Support.tsx @@ -1,12 +1,76 @@ 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 { updateTickets } from "@root/stores/tickets"; +import { enqueueSnackbar } from "notistack"; +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) { + console.log("SSE: ticket received:", event.data); + try { + const newTicket = JSON.parse(event.data) as Ticket; + updateTickets([newTicket]); + } catch (error) { + console.log("Error parsing ticket SSE", error); + } + }, + onError(event) { + console.log("SSE Error:", event); + } + }); + + return () => { + unsubscribe(); + }; + }, []); + + const incrementCurrentPage = () => setCurrentPage(prev => prev + 1); + + const ticketList = return ( {!upMd && - + {ticketList} } - {upMd && } + {upMd && ticketList} ); } \ 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 index fca77cd..f9e98a4 100644 --- a/src/pages/dashboard/Content/Support/TicketList/TicketList.tsx +++ b/src/pages/dashboard/Content/Support/TicketList/TicketList.tsx @@ -1,77 +1,23 @@ import HighlightOffOutlinedIcon from '@mui/icons-material/HighlightOffOutlined'; import SearchOutlinedIcon from '@mui/icons-material/SearchOutlined'; import { Box, Button, useMediaQuery, useTheme } from "@mui/material"; -import { getTickets, subscribeToAllTickets } from "@root/api/tickets"; -import { GetTicketsRequest, Ticket } from "@root/model/ticket"; -import { updateTickets, useTicketStore } from "@root/stores/tickets"; +import { Ticket } from "@root/model/ticket"; +import { useTicketStore } from "@root/stores/tickets"; import { throttle } from '@root/utils/throttle'; -import { enqueueSnackbar } from "notistack"; -import { useEffect, useRef, useState } from "react"; +import { MutableRefObject, useEffect, useRef } from "react"; import TicketItem from "./TicketItem"; -type TicketsFetchState = "idle" | "fetching" | "all fetched"; +interface Props { + fetchingStateRef: MutableRefObject<"idle" | "fetching" | "all fetched">; + incrementCurrentPage: () => void; +} -const TICKETS_PER_PAGE = 20; - -export default function TicketList() { +export default function TicketList({ fetchingStateRef, incrementCurrentPage }: Props) { const theme = useTheme(); const upMd = useMediaQuery(theme.breakpoints.up("md")); const tickets = useTicketStore(state => state.tickets); - const [currentPage, setCurrentPage] = useState(0); const ticketsBoxRef = useRef(null); - const isFetchingTicketsRef = useRef("idle"); - - useEffect(function fetchTickets() { - const getTicketsBody: GetTicketsRequest = { - amt: TICKETS_PER_PAGE, - page: currentPage, - status: "open", - }; - const controller = new AbortController(); - - isFetchingTicketsRef.current = "fetching"; - getTickets({ - body: getTicketsBody, - signal: controller.signal, - }).then(result => { - console.log("GetTicketsResponse", result); - if (result.data) { - updateTickets(result.data); - isFetchingTicketsRef.current = "idle"; - } else isFetchingTicketsRef.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) { - console.log("SSE: ticket received:", event.data); - try { - const newTicket = JSON.parse(event.data) as Ticket; - updateTickets([newTicket]); - } catch (error) { - console.log("Error parsing ticket SSE", error); - } - }, - onError(event) { - console.log("SSE Error:", event); - } - }); - - return () => { - unsubscribe(); - }; - }, []); useEffect(function updateCurrentPageOnScroll() { if (!ticketsBoxRef.current) return; @@ -80,9 +26,9 @@ export default function TicketList() { const scrollHandler = () => { const scrollBottom = ticketsBox.scrollHeight - ticketsBox.scrollTop - ticketsBox.clientHeight; if ( - scrollBottom < ticketsBox.scrollHeight * 0.5 && - isFetchingTicketsRef.current === "idle" - ) setCurrentPage(prev => prev + 1); + scrollBottom < 10 && + fetchingStateRef.current === "idle" + ) incrementCurrentPage(); }; const throttledScrollHandler = throttle(scrollHandler, 200); @@ -91,11 +37,7 @@ export default function TicketList() { return () => { ticketsBox.removeEventListener("scroll", throttledScrollHandler); }; - }, []); - - useEffect(() => { - console.log("currentPage:", currentPage); - }, [currentPage]); + }, [incrementCurrentPage, fetchingStateRef]); const sortedTickets = tickets.sort(sortTicketsByUpdateTime).sort(sortTicketsByUnread); From 60f4b226ffaf8011d6a174e1428b8f126789b2e8 Mon Sep 17 00:00:00 2001 From: nflnkr Date: Wed, 29 Mar 2023 14:04:35 +0300 Subject: [PATCH 36/45] scrollbar dark theme --- src/pages/dashboard/Content/Support/TicketList/TicketList.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/pages/dashboard/Content/Support/TicketList/TicketList.tsx b/src/pages/dashboard/Content/Support/TicketList/TicketList.tsx index f9e98a4..9e2d52e 100644 --- a/src/pages/dashboard/Content/Support/TicketList/TicketList.tsx +++ b/src/pages/dashboard/Content/Support/TicketList/TicketList.tsx @@ -102,6 +102,7 @@ export default function TicketList({ fetchingStateRef, incrementCurrentPage }: P overflow: "auto", overflowY: "auto", padding: "10px", + colorScheme: "dark", }} > {sortedTickets.map(ticket => From a2f3f3f39a6631055dc398e70965bd74a5c93fb7 Mon Sep 17 00:00:00 2001 From: nflnkr Date: Wed, 29 Mar 2023 14:11:27 +0300 Subject: [PATCH 37/45] fix blank message sending --- src/pages/dashboard/Content/Support/Chat/Chat.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/dashboard/Content/Support/Chat/Chat.tsx b/src/pages/dashboard/Content/Support/Chat/Chat.tsx index 7576741..cfeb03b 100644 --- a/src/pages/dashboard/Content/Support/Chat/Chat.tsx +++ b/src/pages/dashboard/Content/Support/Chat/Chat.tsx @@ -93,7 +93,7 @@ export default function Chat() { } function handleSendMessage() { - if (!ticketId) return; + if (!ticketId || !messageField) return; sendTicketMessage({ body: { From b311ac58972707d2842906a29834abd13ddb7c50 Mon Sep 17 00:00:00 2001 From: nflnkr Date: Wed, 29 Mar 2023 14:16:55 +0300 Subject: [PATCH 38/45] log parsed sse data --- src/pages/dashboard/Content/Support/Chat/Chat.tsx | 1 + src/pages/dashboard/Content/Support/Support.tsx | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/pages/dashboard/Content/Support/Chat/Chat.tsx b/src/pages/dashboard/Content/Support/Chat/Chat.tsx index cfeb03b..09f2ae7 100644 --- a/src/pages/dashboard/Content/Support/Chat/Chat.tsx +++ b/src/pages/dashboard/Content/Support/Chat/Chat.tsx @@ -67,6 +67,7 @@ export default function Chat() { try { const newMessage = JSON.parse(event.data) as TicketMessage; + console.log("SSE: parsed newMessage:", newMessage); addOrUpdateMessage([newMessage]); } catch (error) { console.log("Error parsing message SSE", error); diff --git a/src/pages/dashboard/Content/Support/Support.tsx b/src/pages/dashboard/Content/Support/Support.tsx index d07a691..93169f2 100644 --- a/src/pages/dashboard/Content/Support/Support.tsx +++ b/src/pages/dashboard/Content/Support/Support.tsx @@ -53,6 +53,7 @@ export default function Support() { console.log("SSE: ticket received:", event.data); try { const newTicket = JSON.parse(event.data) as Ticket; + console.log("SSE: parsed newTicket:", newTicket); updateTickets([newTicket]); } catch (error) { console.log("Error parsing ticket SSE", error); @@ -70,7 +71,7 @@ export default function Support() { const incrementCurrentPage = () => setCurrentPage(prev => prev + 1); - const ticketList = + const ticketList = ; return ( Date: Wed, 29 Mar 2023 14:45:03 +0300 Subject: [PATCH 39/45] fix SSE logs --- src/pages/dashboard/Content/Support/Chat/Chat.tsx | 4 +--- src/pages/dashboard/Content/Support/Support.tsx | 2 +- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/src/pages/dashboard/Content/Support/Chat/Chat.tsx b/src/pages/dashboard/Content/Support/Chat/Chat.tsx index 09f2ae7..46d8df3 100644 --- a/src/pages/dashboard/Content/Support/Chat/Chat.tsx +++ b/src/pages/dashboard/Content/Support/Chat/Chat.tsx @@ -62,14 +62,12 @@ export default function Chat() { ticketId, accessToken: token, onMessage(event) { - console.log("SSE: message received:", event.data); - if (event.data === "no tickets 4 user") return; - try { const newMessage = JSON.parse(event.data) as TicketMessage; console.log("SSE: parsed newMessage:", newMessage); addOrUpdateMessage([newMessage]); } catch (error) { + console.log("SSE: couldn't parse:", event.data); console.log("Error parsing message SSE", error); } }, diff --git a/src/pages/dashboard/Content/Support/Support.tsx b/src/pages/dashboard/Content/Support/Support.tsx index 93169f2..ae8f428 100644 --- a/src/pages/dashboard/Content/Support/Support.tsx +++ b/src/pages/dashboard/Content/Support/Support.tsx @@ -50,12 +50,12 @@ export default function Support() { const unsubscribe = subscribeToAllTickets({ accessToken: token, onMessage(event) { - console.log("SSE: ticket received:", event.data); 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); } }, From 322fa868798081e79435715447bc952e100bdd44 Mon Sep 17 00:00:00 2001 From: nflnkr Date: Wed, 29 Mar 2023 15:51:36 +0300 Subject: [PATCH 40/45] refactor --- .../dashboard/Content/Support/Chat/Chat.tsx | 151 ++++++++++-------- 1 file changed, 80 insertions(+), 71 deletions(-) diff --git a/src/pages/dashboard/Content/Support/Chat/Chat.tsx b/src/pages/dashboard/Content/Support/Chat/Chat.tsx index 46d8df3..cfcbc9e 100644 --- a/src/pages/dashboard/Content/Support/Chat/Chat.tsx +++ b/src/pages/dashboard/Content/Support/Chat/Chat.tsx @@ -1,4 +1,4 @@ -import { Box, IconButton, InputAdornment, TextField, useMediaQuery, useTheme } from "@mui/material"; +import { Box, IconButton, InputAdornment, TextField, Typography, useMediaQuery, useTheme } from "@mui/material"; import { addOrUpdateMessage, setMessages, useMessageStore } from "@root/stores/messages"; import Message from "./Message"; import SendIcon from "@mui/icons-material/Send"; @@ -27,13 +27,13 @@ export default function Chat() { }, [messages]); useEffect(function fetchTicketMessages() { - if (!ticketId) return; + if (!ticket) return; const getTicketsBody: GetMessagesRequest = { amt: 100, // TODO use pagination page: 0, srch: "", - ticket: ticketId, + ticket: ticket.id, }; const controller = new AbortController(); @@ -50,16 +50,16 @@ export default function Chat() { }); return () => controller.abort(); - }, [ticketId]); + }, [ticket]); useEffect(function subscribeToMessages() { - if (!ticketId) return; + if (!ticket) return; const token = localStorage.getItem("AT"); if (!token) return; const unsubscribe = subscribeToTicketMessages({ - ticketId, + ticketId: ticket.id, accessToken: token, onMessage(event) { try { @@ -79,7 +79,7 @@ export default function Chat() { return () => { unsubscribe(); }; - }, [ticketId]); + }, [ticket]); function scrollToBottom() { if (!chatBoxRef.current) return; @@ -92,14 +92,14 @@ export default function Chat() { } function handleSendMessage() { - if (!ticketId || !messageField) return; + if (!ticket || !messageField) return; sendTicketMessage({ body: { files: [], lang: "ru", message: messageField, - ticket: ticketId, + ticket: ticket.id, } }); setMessageField(""); @@ -128,70 +128,79 @@ export default function Chat() { display: "flex", flex: upMd ? "2 0 0" : undefined, flexDirection: "column", + justifyContent: "center", + alignItems: "center", gap: "8px", }}> - - {ticket && 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, - } - }} - /> + {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, + } + }} + /> + + : + Выберите тикет} ); } From 6a19103bf90123445dd67d69e8c1b24644532114 Mon Sep 17 00:00:00 2001 From: nflnkr Date: Wed, 29 Mar 2023 15:52:36 +0300 Subject: [PATCH 41/45] add ticket header, selected ticket style --- .../Content/Support/TicketList/TicketItem.tsx | 40 +++++++++++++++---- .../Content/Support/TicketList/TicketList.tsx | 2 +- 2 files changed, 33 insertions(+), 9 deletions(-) diff --git a/src/pages/dashboard/Content/Support/TicketList/TicketItem.tsx b/src/pages/dashboard/Content/Support/TicketList/TicketItem.tsx index 08c6b74..b0366f8 100644 --- a/src/pages/dashboard/Content/Support/TicketList/TicketItem.tsx +++ b/src/pages/dashboard/Content/Support/TicketList/TicketItem.tsx @@ -1,8 +1,8 @@ -import CircleIcon from '@mui/icons-material/Circle'; -import { Box, Card, CardActionArea, CardContent, useTheme } from "@mui/material"; -import { green } from '@mui/material/colors'; +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 } from 'react-router-dom'; +import { useNavigate, useParams } from "react-router-dom"; const flexCenterSx = { @@ -14,13 +14,16 @@ const flexCenterSx = { }; interface Props { - isUnread?: boolean; ticket: Ticket; } -export default function TicketItem({ isUnread, ticket }: Props) { +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", @@ -28,6 +31,10 @@ export default function TicketItem({ isUnread, ticket }: Props) { backgroundColor: theme.palette.goldenMedium.main }; + const selectedSx = { + border: `2px solid ${theme.palette.secondary.main}`, + }; + function handleCardClick() { navigate(`/support/${ticket.id}`); } @@ -37,18 +44,35 @@ export default function TicketItem({ isUnread, ticket }: Props) { minHeight: "70px", backgroundColor: "transparent", color: "white", + ...(isUnread && unreadSx), + ...(isSelected && selectedSx), }}> + {ticket.title}} + disableTypography + sx={{ + textAlign: "center", + p: "4px", + }} + /> + {new Date(ticket.top_message.created_at).toLocaleDateString()} - + {ticket.top_message.message} diff --git a/src/pages/dashboard/Content/Support/TicketList/TicketList.tsx b/src/pages/dashboard/Content/Support/TicketList/TicketList.tsx index 9e2d52e..0896e60 100644 --- a/src/pages/dashboard/Content/Support/TicketList/TicketList.tsx +++ b/src/pages/dashboard/Content/Support/TicketList/TicketList.tsx @@ -106,7 +106,7 @@ export default function TicketList({ fetchingStateRef, incrementCurrentPage }: P }} > {sortedTickets.map(ticket => - + )} From e99ef8b47c2f847da00e0231ef0fe80466add5dc Mon Sep 17 00:00:00 2001 From: nflnkr Date: Wed, 29 Mar 2023 17:17:00 +0300 Subject: [PATCH 42/45] minor refactor --- src/api/tickets.ts | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/src/api/tickets.ts b/src/api/tickets.ts index e17eff3..43f3aa0 100644 --- a/src/api/tickets.ts +++ b/src/api/tickets.ts @@ -11,12 +11,8 @@ export function subscribeToAllTickets({ onMessage, onError, accessToken }: { onError: (e: Event) => void; }) { const url = `${supportApiUrl}/subscribe?Authorization=${accessToken}`; - 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); + const eventSource = createEventSource(onMessage, onError, url); return () => { eventSource.close(); @@ -30,12 +26,8 @@ export function subscribeToTicketMessages({ onMessage, onError, accessToken, tic onError: (e: Event) => void; }) { const url = `${supportApiUrl}/ticket?ticket=${ticketId}&Authorization=${accessToken}`; - 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); + const eventSource = createEventSource(onMessage, onError, url); return () => { eventSource.close(); @@ -83,4 +75,15 @@ export async function sendTicketMessage({ body }: { 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 From 8b4af3500f9ee13bee7a9f9457d27fc05c890067 Mon Sep 17 00:00:00 2001 From: ArtChaos189 Date: Thu, 30 Mar 2023 14:11:34 +0300 Subject: [PATCH 43/45] test ticket --- babel.config.js | 6 ++++ jest.config.js | 5 +++ src/__tests__/tickets.test.ts | 57 +++++++++++++++++++++++++++++++++++ 3 files changed, 68 insertions(+) create mode 100644 babel.config.js create mode 100644 jest.config.js create mode 100644 src/__tests__/tickets.test.ts 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/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", + }); + }); +}); From 44f07008f656632da61aa53499514d2143b302c0 Mon Sep 17 00:00:00 2001 From: nflnkr Date: Fri, 31 Mar 2023 18:06:55 +0300 Subject: [PATCH 44/45] fix type --- src/model/ticket.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/model/ticket.ts b/src/model/ticket.ts index 2268f48..27ffaf0 100644 --- a/src/model/ticket.ts +++ b/src/model/ticket.ts @@ -59,7 +59,7 @@ export interface TicketMessage { export interface GetMessagesRequest { amt: number; page: number; - srch: string; + srch?: string; ticket: string; }; From f3296b81f039d65f3dfde41beefe93d9c69b5dd4 Mon Sep 17 00:00:00 2001 From: nflnkr Date: Fri, 31 Mar 2023 18:07:37 +0300 Subject: [PATCH 45/45] refactor --- .../dashboard/Content/Support/Chat/Chat.tsx | 23 ++++++++++--------- .../dashboard/Content/Support/Support.tsx | 5 +++- src/stores/messages.ts | 8 ++++--- src/stores/tickets.ts | 6 +++-- 4 files changed, 25 insertions(+), 17 deletions(-) diff --git a/src/pages/dashboard/Content/Support/Chat/Chat.tsx b/src/pages/dashboard/Content/Support/Chat/Chat.tsx index cfcbc9e..3d8ad17 100644 --- a/src/pages/dashboard/Content/Support/Chat/Chat.tsx +++ b/src/pages/dashboard/Content/Support/Chat/Chat.tsx @@ -1,5 +1,5 @@ import { Box, IconButton, InputAdornment, TextField, Typography, useMediaQuery, useTheme } from "@mui/material"; -import { addOrUpdateMessage, setMessages, useMessageStore } from "@root/stores/messages"; +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"; @@ -27,17 +27,15 @@ export default function Chat() { }, [messages]); useEffect(function fetchTicketMessages() { - if (!ticket) return; + if (!ticketId) return; const getTicketsBody: GetMessagesRequest = { amt: 100, // TODO use pagination page: 0, - srch: "", - ticket: ticket.id, + ticket: ticketId, }; const controller = new AbortController(); - setMessages([]); getTicketMessages({ body: getTicketsBody, signal: controller.signal, @@ -49,23 +47,26 @@ export default function Chat() { enqueueSnackbar(error.message); }); - return () => controller.abort(); - }, [ticket]); + return () => { + controller.abort(); + clearMessages(); + }; + }, [ticketId]); useEffect(function subscribeToMessages() { - if (!ticket) return; + if (!ticketId) return; const token = localStorage.getItem("AT"); if (!token) return; const unsubscribe = subscribeToTicketMessages({ - ticketId: ticket.id, + ticketId, accessToken: token, onMessage(event) { try { const newMessage = JSON.parse(event.data) as TicketMessage; console.log("SSE: parsed newMessage:", newMessage); - addOrUpdateMessage([newMessage]); + addOrUpdateMessages([newMessage]); } catch (error) { console.log("SSE: couldn't parse:", event.data); console.log("Error parsing message SSE", error); @@ -79,7 +80,7 @@ export default function Chat() { return () => { unsubscribe(); }; - }, [ticket]); + }, [ticketId]); function scrollToBottom() { if (!chatBoxRef.current) return; diff --git a/src/pages/dashboard/Content/Support/Support.tsx b/src/pages/dashboard/Content/Support/Support.tsx index ae8f428..361dfc9 100644 --- a/src/pages/dashboard/Content/Support/Support.tsx +++ b/src/pages/dashboard/Content/Support/Support.tsx @@ -5,8 +5,9 @@ import Collapse from "./Collapse"; import TicketList from "./TicketList/TicketList"; import { getTickets, subscribeToAllTickets } from "@root/api/tickets"; import { GetTicketsRequest, Ticket } from "@root/model/ticket"; -import { updateTickets } from "@root/stores/tickets"; +import { clearTickets, updateTickets } from "@root/stores/tickets"; import { enqueueSnackbar } from "notistack"; +import { clearMessages } from "@root/stores/messages"; const TICKETS_PER_PAGE = 20; @@ -66,6 +67,8 @@ export default function Support() { return () => { unsubscribe(); + clearMessages(); + clearTickets(); }; }, []); diff --git a/src/stores/messages.ts b/src/stores/messages.ts index 6ba13f4..bffbee9 100644 --- a/src/stores/messages.ts +++ b/src/stores/messages.ts @@ -14,18 +14,20 @@ export const useMessageStore = create()( messages: testMessages, }), { - name: "Message store" + name: "Message store (admin)" } ) ); export const setMessages = (messages: TicketMessage[]) => useMessageStore.setState(({ messages })); -export const addOrUpdateMessage = (receivedMessages: TicketMessage[]) => { +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) }); -}; \ No newline at end of file +}; + +export const clearMessages = () => useMessageStore.setState({ messages: [] }); \ No newline at end of file diff --git a/src/stores/tickets.ts b/src/stores/tickets.ts index 9b9e558..e3a07cf 100644 --- a/src/stores/tickets.ts +++ b/src/stores/tickets.ts @@ -13,7 +13,7 @@ export const useTicketStore = create()( tickets: [], }), { - name: "Tickets store" + name: "Tickets store (admin)" } ) ); @@ -25,4 +25,6 @@ export const updateTickets = (receivedTickets: Ticket[]) => { [...state.tickets, ...receivedTickets].forEach(ticket => ticketIdToTicketMap[ticket.id] = ticket); useTicketStore.setState({ tickets: Object.values(ticketIdToTicketMap) }); -}; \ No newline at end of file +}; + +export const clearTickets = () => useTicketStore.setState({ tickets: [] }); \ No newline at end of file