Merge branch 'with-kitui-package' into dev

This commit is contained in:
nflnkr 2023-06-17 18:19:12 +03:00
commit a74473b484
30 changed files with 298 additions and 22417 deletions

1
.yarnrc Normal file

@ -0,0 +1 @@
"@frontend:registry" "https://penahub.gitlab.yandexcloud.net/api/v4/packages/npm/"

21683
package-lock.json generated

File diff suppressed because it is too large Load Diff

@ -11,6 +11,7 @@
"dependencies": {
"@emotion/react": "^11.10.5",
"@emotion/styled": "^11.10.5",
"@frontend/kitui": "^1.0.6",
"@mui/icons-material": "^5.10.14",
"@mui/material": "^5.10.14",
"axios": "^1.3.4",
@ -21,7 +22,6 @@
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.4.3",
"reconnecting-eventsource": "^1.6.2",
"web-vitals": "^2.1.0",
"yup": "^1.1.1",
"zustand": "^4.3.6"

@ -1,10 +1,8 @@
import { authStore } from "@root/stores/makeRequest";
import { makeRequest } from "@frontend/kitui";
const apiUrl = process.env.NODE_ENV === "production" ? "" : "https://hub.pena.digital";
const makeRequest = authStore.getState().makeRequest;
export function logout() {
return makeRequest<never, void>({
url: apiUrl + "/auth/logout",

@ -1,138 +0,0 @@
import { CreateTicketRequest, CreateTicketResponse, GetMessagesRequest, GetMessagesResponse, GetTicketsRequest, GetTicketsResponse, SendTicketMessageRequest } from "@root/model/ticket";
import { authStore } from "@root/stores/makeRequest";
import ReconnectingEventSource from "reconnecting-eventsource";
const makeRequest = authStore.getState().makeRequest;
const supportApiUrl = "https://hub.pena.digital/heruvym";
export function subscribeToAllTickets({ onMessage, onError, accessToken }: {
accessToken: string;
onMessage: (e: MessageEvent) => void;
onError: (e: Event) => void;
}) {
const url = `${supportApiUrl}/subscribe?Authorization=${accessToken}`;
const eventSource = createEventSource(onMessage, onError, url);
return () => {
eventSource.close();
};
}
export function subscribeToTicketMessages({ onMessage, onError, accessToken, ticketId }: {
accessToken: string;
ticketId: string;
onMessage: (e: MessageEvent) => void;
onError: (e: Event) => void;
}) {
const url = `${supportApiUrl}/ticket?ticket=${ticketId}&Authorization=${accessToken}`;
const eventSource = createEventSource(onMessage, onError, url);
return () => {
eventSource.close();
};
}
export function subscribeToUnauthTicketMessages({ onMessage, onError, sessionId, ticketId }: {
ticketId: string;
sessionId: string;
onMessage: (e: MessageEvent) => void;
onError: (e: Event) => void;
}) {
const url = `${supportApiUrl}/ticket?ticket=${ticketId}&s=${sessionId}`;
const eventSource = createEventSource(onMessage, onError, url);
return () => {
eventSource.close();
};
}
export function getTickets({ body, signal }: {
body: GetTicketsRequest;
signal: AbortSignal;
}): Promise<GetTicketsResponse> {
return makeRequest<GetTicketsRequest, GetTicketsResponse>({
url: `${supportApiUrl}/getTickets`,
method: "POST",
useToken: true,
body,
signal,
});
}
export function getUnauthTicket(signal: AbortSignal): Promise<GetTicketsResponse> {
return makeRequest<GetTicketsRequest, GetTicketsResponse>({
url: `${supportApiUrl}/getTickets`,
method: "POST",
useToken: false,
body: {
amt: 1,
page: 0,
status: "open",
},
signal,
withCredentials: true,
});
}
export function getTicketMessages({ body, signal }: {
body: GetMessagesRequest;
signal: AbortSignal;
}): Promise<GetMessagesResponse> {
return makeRequest({
url: `${supportApiUrl}/getMessages`,
method: "POST",
useToken: true,
body,
signal,
});
}
export function getUnauthTicketMessages({ body, signal }: {
body: GetMessagesRequest;
signal: AbortSignal;
}): Promise<GetMessagesResponse> {
return makeRequest({
url: `${supportApiUrl}/getMessages`,
method: "POST",
useToken: false,
body,
signal,
withCredentials: true,
});
}
export function sendTicketMessage(body: SendTicketMessageRequest, withCredentials?: boolean) {
return makeRequest({
url: `${supportApiUrl}/send`,
method: "POST",
useToken: !withCredentials,
body,
withCredentials,
});
}
export function createTicket(body: CreateTicketRequest, useToken = true): Promise<CreateTicketResponse> {
return makeRequest({
url: `${supportApiUrl}/create`,
method: "POST",
useToken,
body,
withCredentials: true,
});
}
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;
}

@ -1,21 +1,9 @@
import { makeRequest } from "@frontend/kitui";
import { PatchUserRequest, User } from "@root/model/user";
import { authStore } from "@root/stores/makeRequest";
const apiUrl = process.env.NODE_ENV === "production" ? "" : "https://hub.pena.digital";
const makeRequest = authStore.getState().makeRequest;
export function getUser(userId: string): Promise<User | null> {
return makeRequest<never, User>({
url: `${apiUrl}/user/${userId}`,
contentType: true,
method: "GET",
useToken: false,
withCredentials: false,
});
}
export function patchUser(user: PatchUserRequest) {
return makeRequest<PatchUserRequest, User>({
url: apiUrl + "/user/",

@ -1,14 +1,14 @@
import { Box, FormControl, IconButton, InputAdornment, InputBase, SxProps, Theme, Typography, useMediaQuery, useTheme } from "@mui/material";
import { createTicket, getUnauthTicketMessages, sendTicketMessage, subscribeToUnauthTicketMessages } from "@root/api/tickets";
import { GetMessagesRequest, TicketMessage } from "@root/model/ticket";
import { addOrUpdateUnauthMessages, setUnauthTicketFetchState, useUnauthTicketStore, incrementUnauthMessageApiPage, setUnauthIsPreventAutoscroll, setUnauthSessionData, setIsMessageSending } from "@root/stores/unauthTicket";
import { TicketMessage } from "@frontend/kitui";
import { addOrUpdateUnauthMessages, useUnauthTicketStore, incrementUnauthMessageApiPage, setUnauthIsPreventAutoscroll, setUnauthSessionData, setIsMessageSending } from "@root/stores/unauthTicket";
import { enqueueSnackbar } from "notistack";
import { useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import ChatMessage from "../ChatMessage";
import SendIcon from "../icons/SendIcon";
import UserCircleIcon from "./UserCircleIcon";
import { throttle } from "@root/utils/decorators";
import { getMessageFromFetchError } from "@root/utils/backendMessageHandler";
import { throttle } from "@frontend/kitui";
import { makeRequest } from "@frontend/kitui";
import { useTicketMessages, getMessageFromFetchError, useSSESubscription, useEventListener, createTicket } from "@frontend/kitui";
interface Props {
@ -24,93 +24,52 @@ export default function Chat({ sx }: Props) {
const messageApiPage = useUnauthTicketStore(state => state.apiPage);
const messagesPerPage = useUnauthTicketStore(state => state.messagesPerPage);
const isMessageSending = useUnauthTicketStore(state => state.isMessageSending);
const messagesFetchStateRef = useRef(useUnauthTicketStore.getState().fetchState);
const isPreventAutoscroll = useUnauthTicketStore(state => state.isPreventAutoscroll);
const lastMessageId = useUnauthTicketStore(state => state.lastMessageId);
const chatBoxRef = useRef<HTMLDivElement>();
const chatBoxRef = useRef<HTMLDivElement>(null);
useEffect(function fetchTicketMessages() {
if (!sessionData) return;
const fetchState = useTicketMessages({
url: "https://admin.pena.digital/heruvym/getMessages",
isUnauth: true,
ticketId: sessionData?.ticketId,
messagesPerPage,
messageApiPage,
onNewMessages: useCallback(messages => {
if (chatBoxRef.current && chatBoxRef.current.scrollTop < 1) chatBoxRef.current.scrollTop = 1;
addOrUpdateUnauthMessages(messages);
}, []),
onError: useCallback((error: Error) => {
const message = getMessageFromFetchError(error);
if (message) enqueueSnackbar(message);
}, []),
});
const getTicketsBody: GetMessagesRequest = {
amt: messagesPerPage,
page: messageApiPage,
ticket: sessionData.ticketId,
};
const controller = new AbortController();
setUnauthTicketFetchState("fetching");
getUnauthTicketMessages({
body: getTicketsBody,
signal: controller.signal,
}).then(result => {
console.log("GetMessagesResponse", result);
if (result?.length > 0) {
if (chatBoxRef.current && chatBoxRef.current.scrollTop < 1) chatBoxRef.current.scrollTop = 1;
addOrUpdateUnauthMessages(result);
setUnauthTicketFetchState("idle");
} else setUnauthTicketFetchState("all fetched");
}).catch(error => {
console.log("Error fetching messages", error);
if (error.code !== "ERR_CANCELED") enqueueSnackbar(error.message);
});
return () => {
controller.abort();
};
}, [messageApiPage, messagesPerPage, sessionData]);
useEffect(function subscribeToMessages() {
if (!sessionData) return;
const unsubscribe = subscribeToUnauthTicketMessages({
sessionId: sessionData.sessionId,
ticketId: sessionData.ticketId,
onMessage(event) {
try {
const newMessage = JSON.parse(event.data) as TicketMessage;
if (!newMessage.id) throw new Error("Bad SSE response");
console.log("SSE: parsed newMessage:", newMessage);
addOrUpdateUnauthMessages([newMessage]);
} catch (error) {
console.log("SSE: couldn't parse:", event.data);
console.log("Error parsing SSE message", error);
}
},
onError(event) {
console.log("SSE Error:", event);
},
});
return () => {
unsubscribe();
useSSESubscription<TicketMessage>({
enabled: Boolean(sessionData),
url: `https://hub.pena.digital/heruvym/ticket?ticket=${sessionData?.ticketId}&s=${sessionData?.sessionId}`,
onNewData: addOrUpdateUnauthMessages,
onDisconnect: useCallback(() => {
setUnauthIsPreventAutoscroll(false);
};
}, [sessionData]);
useEffect(function attachScrollHandler() {
if (!chatBoxRef.current) return;
}, []),
marker: "ticket"
});
const throttledScrollHandler = useMemo(() => throttle(() => {
const chatBox = chatBoxRef.current;
const scrollHandler = () => {
const scrollBottom = chatBox.scrollHeight - chatBox.scrollTop - chatBox.clientHeight;
const isPreventAutoscroll = scrollBottom > chatBox.clientHeight;
setUnauthIsPreventAutoscroll(isPreventAutoscroll);
if (!chatBox) return;
if (messagesFetchStateRef.current !== "idle") return;
const scrollBottom = chatBox.scrollHeight - chatBox.scrollTop - chatBox.clientHeight;
const isPreventAutoscroll = scrollBottom > chatBox.clientHeight;
setUnauthIsPreventAutoscroll(isPreventAutoscroll);
if (chatBox.scrollTop < chatBox.clientHeight) {
incrementUnauthMessageApiPage();
}
};
if (fetchState !== "idle") return;
const throttledScrollHandler = throttle(scrollHandler, 200);
chatBox.addEventListener("scroll", throttledScrollHandler);
if (chatBox.scrollTop < chatBox.clientHeight) {
incrementUnauthMessageApiPage();
}
}, 200), [fetchState]);
return () => {
chatBox.removeEventListener("scroll", throttledScrollHandler);
};
}, []);
useEventListener("scroll", throttledScrollHandler, chatBoxRef);
useEffect(function scrollOnNewMessage() {
if (!chatBoxRef.current) return;
@ -123,17 +82,19 @@ export default function Chat({ sx }: Props) {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [lastMessageId]);
useEffect(() => useUnauthTicketStore.subscribe(state => (messagesFetchStateRef.current = state.fetchState)), []);
async function handleSendMessage() {
if (!messageField || isMessageSending) return;
if (!sessionData) {
setIsMessageSending(true);
createTicket({
Title: "Unauth title",
Message: messageField,
}, false).then(response => {
url: "https://hub.pena.digital/heruvym/create",
body: {
Title: "Unauth title",
Message: messageField,
},
useToken: false,
}).then(response => {
setUnauthSessionData({
ticketId: response.Ticket,
sessionId: response.sess,
@ -147,12 +108,18 @@ export default function Chat({ sx }: Props) {
});
} else {
setIsMessageSending(true);
sendTicketMessage({
ticket: sessionData.ticketId,
message: messageField,
lang: "ru",
files: [],
}, true).catch(error => {
makeRequest({
url: "https://hub.pena.digital/heruvym/send",
method: "POST",
useToken: false,
body: {
ticket: sessionData.ticketId,
message: messageField,
lang: "ru",
files: [],
},
withCredentials: true,
}).catch(error => {
const errorMessage = getMessageFromFetchError(error);
if (errorMessage) enqueueSnackbar(errorMessage);
}).finally(() => {

@ -4,7 +4,6 @@ import { Box, Button, Container, IconButton, Typography, useTheme } from "@mui/m
import SectionWrapper from "../SectionWrapper";
import { basketStore } from "@stores/BasketStore";
import { authStore } from "@stores/makeRequest";
import LogoutIcon from "../icons/LogoutIcon";
import WalletIcon from "../icons/WalletIcon";
@ -15,7 +14,8 @@ import Menu from "../Menu";
import { logout } from "@root/api/auth";
import { enqueueSnackbar } from "notistack";
import { clearUser, useUserStore } from "@root/stores/user";
import { getMessageFromFetchError } from "@root/utils/backendMessageHandler";
import { getMessageFromFetchError } from "@frontend/kitui";
import { clearToken } from "@root/stores/auth";
interface Props {
isLoggedIn: boolean;
@ -23,7 +23,6 @@ interface Props {
export default function NavbarFull({ isLoggedIn }: Props) {
const theme = useTheme();
const { clearToken } = authStore();
const location = useLocation();
const navigate = useNavigate();
const user = useUserStore((state) => state.user);

@ -1,4 +1,4 @@
import React, { useEffect } from "react";
import React, { useCallback } from "react";
import ReactDOM from "react-dom/client";
import { BrowserRouter, Navigate, Route, Routes, useLocation } from "react-router-dom";
import { CssBaseline, ThemeProvider } from "@mui/material";
@ -20,26 +20,24 @@ import reportWebVitals from "./reportWebVitals";
import { SnackbarProvider, enqueueSnackbar } from "notistack";
import "./index.css";
import Layout from "./components/Layout";
import { getUser } from "./api/user";
import { setUser, useUserStore } from "./stores/user";
import TariffConstructor from "./pages/TariffConstructor/TariffConstructor";
import { useUser } from "./utils/hooks/useUser";
const App = () => {
const location = useLocation();
const userId = useUserStore(state => state.userId);
useEffect(function fetchUserData() {
if (!userId) return;
getUser(userId).then(result => {
setUser(result);
}).catch(error => {
console.log("Error fetching user", error);
useUser({
url: `https://hub.pena.digital/user/${userId}`,
userId,
onNewUser: setUser,
onError: useCallback(error => {
enqueueSnackbar(error.response?.data?.message ?? error.message ?? "Error fetching user");
setUser(null);
});
}, [userId]);
}, [])
});
if (location.state?.redirectTo) return <Navigate to={location.state.redirectTo} replace state={{ backgroundLocation: location }} />;

@ -1,67 +0,0 @@
export interface CreateTicketRequest {
Title: string;
Message: string;
};
export interface CreateTicketResponse {
Ticket: string;
sess: string;
};
export interface SendTicketMessageRequest {
message: string;
ticket: string;
lang: string;
files: string[];
};
export type TicketStatus = "open"; // TODO
export interface GetTicketsRequest {
amt: number;
/** Пагинация начинается с индекса 0 */
page: number;
srch?: string;
status?: TicketStatus;
};
export interface GetTicketsResponse {
count: number;
data: Ticket[] | null;
};
export interface Ticket {
id: string;
user: string;
sess: string;
ans: string;
state: string;
top_message: TicketMessage;
title: string;
created_at: string;
updated_at: string;
rate: number;
};
export interface TicketMessage {
id: string;
ticket_id: string;
user_id: string,
session_id: string;
message: string;
files: string[],
shown: { [key: string]: number; },
request_screenshot: string,
created_at: string;
};
export interface GetMessagesRequest {
amt: number;
page: number;
srch?: string;
ticket: string;
};
export type GetMessagesResponse = TicketMessage[];

@ -2,9 +2,9 @@ import { Box, Typography, FormControl, InputBase, useMediaQuery, useTheme } from
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import CustomButton from "@components/CustomButton";
import { createTicket } from "@root/api/tickets";
import { enqueueSnackbar } from "notistack";
import { cardShadow } from "@root/utils/themes/shadow";
import { createTicket } from "@frontend/kitui";
export default function CreateTicket() {
@ -18,8 +18,11 @@ export default function CreateTicket() {
if (!ticketBodyField || !ticketNameField) return;
createTicket({
Title: ticketNameField,
Message: ticketBodyField,
url: "https://hub.pena.digital/heruvym/create",
body: {
Title: ticketNameField,
Message: ticketBodyField,
}
}).then(result => {
navigate(`/support/${result.Ticket}`);
}).catch(error => {

@ -6,12 +6,12 @@ import ComplexNavText from "@components/ComplexNavText";
import SupportChat from "./SupportChat";
import CreateTicket from "./CreateTicket";
import TicketList from "./TicketList/TicketList";
import { useEffect } from "react";
import { getTickets, subscribeToAllTickets } from "@root/api/tickets";
import { GetTicketsRequest, Ticket } from "@root/model/ticket";
import { updateTickets, setTicketCount, clearTickets, useTicketStore, setTicketsFetchState } from "@root/stores/tickets";
import { useCallback } from "react";
import { Ticket } from "@frontend/kitui";
import { updateTickets, setTicketCount, clearTickets, useTicketStore } from "@root/stores/tickets";
import { enqueueSnackbar } from "notistack";
import { authStore } from "@root/stores/makeRequest";
import { useAuthStore } from "@root/stores/auth";
import { useSSESubscription, useTickets } from "@frontend/kitui";
export default function Support() {
@ -20,61 +20,30 @@ export default function Support() {
const ticketId = useParams().ticketId;
const ticketApiPage = useTicketStore(state => state.apiPage);
const ticketsPerPage = useTicketStore(state => state.ticketsPerPage);
const token = authStore(state => state.token);
const token = useAuthStore(state => state.token);
useEffect(function fetchTickets() {
const getTicketsBody: GetTicketsRequest = {
amt: ticketsPerPage,
page: ticketApiPage,
status: "open",
};
const controller = new AbortController();
setTicketsFetchState("fetching");
getTickets({
body: getTicketsBody,
signal: controller.signal,
}).then(result => {
console.log("GetTicketsResponse", result);
if (result.data) {
updateTickets(result.data);
setTicketCount(result.count);
setTicketsFetchState("idle");
} else setTicketsFetchState("all fetched");
}).catch(error => {
console.log("Error fetching tickets", error);
const fetchState = useTickets({
url: "https://hub.pena.digital/heruvym/getTickets",
ticketsPerPage,
ticketApiPage,
onNewTickets: useCallback(result => {
if (result.data) updateTickets(result.data);
setTicketCount(result.count);
}, []),
onError: useCallback((error: Error) => {
enqueueSnackbar(error.message);
});
}, [])
});
return () => controller.abort();
}, [ticketApiPage, ticketsPerPage]);
useEffect(function subscribeToTickets() {
if (!token) return;
const unsubscribe = subscribeToAllTickets({
accessToken: token,
onMessage(event) {
try {
const newTicket = JSON.parse(event.data) as Ticket;
console.log("SSE: parsed newTicket:", newTicket);
if ((newTicket as any).count) return; // Костыль пока бэк не починят
updateTickets([newTicket]);
} catch (error) {
console.log("SSE: couldn't parse:", event.data);
console.log("Error parsing ticket SSE", error);
}
},
onError(event) {
console.log("SSE Error:", event);
}
});
return () => {
unsubscribe();
useSSESubscription<Ticket>({
enabled: Boolean(token),
url: `https://admin.pena.digital/heruvym/subscribe?Authorization=${token}`,
onNewData: updateTickets,
onDisconnect: useCallback(() => {
clearTickets();
};
}, [token]);
}, []),
marker: "ticket"
});
return (
<SectionWrapper
@ -112,7 +81,7 @@ export default function Support() {
}}
>
<CreateTicket />
<TicketList />
<TicketList fetchState={fetchState} />
</Box>
)}
</SectionWrapper>

@ -1,18 +1,18 @@
import { Box, Fab, FormControl, IconButton, InputAdornment, InputBase, Typography, useMediaQuery, useTheme, } from "@mui/material";
import ArrowDownwardIcon from "@mui/icons-material/ArrowDownward";
import { useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useParams } from "react-router-dom";
import CustomButton from "@components/CustomButton";
import SendIcon from "@components/icons/SendIcon";
import { throttle } from "@utils/decorators";
import { makeRequest, throttle } from "@frontend/kitui";
import { enqueueSnackbar } from "notistack";
import { useTicketStore } from "@root/stores/tickets";
import { addOrUpdateMessages, clearMessageState, incrementMessageApiPage, setIsPreventAutoscroll, setMessageFetchState, useMessageStore } from "@root/stores/messages";
import { getTicketMessages, sendTicketMessage, subscribeToTicketMessages } from "@root/api/tickets";
import { GetMessagesRequest, TicketMessage } from "@root/model/ticket";
import { authStore } from "@root/stores/makeRequest";
import { addOrUpdateMessages, clearMessageState, incrementMessageApiPage, setIsPreventAutoscroll, useMessageStore } from "@root/stores/messages";
import { TicketMessage } from "@frontend/kitui";
import ChatMessage from "@root/components/ChatMessage";
import { cardShadow } from "@root/utils/themes/shadow";
import { useAuthStore } from "@root/stores/auth";
import { getMessageFromFetchError, useEventListener, useSSESubscription, useTicketMessages } from "@frontend/kitui";
export default function SupportChat() {
@ -25,93 +25,52 @@ export default function SupportChat() {
const lastMessageId = useMessageStore(state => state.lastMessageId);
const messagesPerPage = useMessageStore(state => state.messagesPerPage);
const isPreventAutoscroll = useMessageStore(state => state.isPreventAutoscroll);
const messagesFetchStateRef = useRef(useMessageStore.getState().fetchState);
const token = authStore(state => state.token);
const token = useAuthStore(state => state.token);
const ticketId = useParams().ticketId;
const ticket = tickets.find(ticket => ticket.id === ticketId);
const chatBoxRef = useRef<HTMLDivElement>();
const chatBoxRef = useRef<HTMLDivElement>(null);
useEffect(function fetchTicketMessages() {
if (!ticketId) return;
const getTicketsBody: GetMessagesRequest = {
amt: messagesPerPage,
page: messageApiPage,
ticket: ticketId,
};
const controller = new AbortController();
setMessageFetchState("fetching");
getTicketMessages({
body: getTicketsBody,
signal: controller.signal,
}).then(result => {
console.log("GetMessagesResponse", result);
if (result?.length > 0) {
if (chatBoxRef.current && chatBoxRef.current.scrollTop < 1) chatBoxRef.current.scrollTop = 1;
addOrUpdateMessages(result);
setMessageFetchState("idle");
} else setMessageFetchState("all fetched");
}).catch(error => {
console.log("Error fetching messages", error);
const fetchState = useTicketMessages({
url: "https://admin.pena.digital/heruvym/getMessages",
ticketId,
messagesPerPage,
messageApiPage,
onNewMessages: useCallback(messages => {
if (chatBoxRef.current && chatBoxRef.current.scrollTop < 1) chatBoxRef.current.scrollTop = 1;
addOrUpdateMessages(messages);
}, []),
onError: useCallback((error: Error) => {
enqueueSnackbar(error.message);
});
}, []),
});
return () => {
controller.abort();
};
}, [messageApiPage, messagesPerPage, ticketId]);
useEffect(function subscribeToMessages() {
if (!ticketId || !token) return;
const unsubscribe = subscribeToTicketMessages({
ticketId: ticketId,
accessToken: token,
onMessage(event) {
try {
const newMessage = JSON.parse(event.data) as TicketMessage;
console.log("SSE: parsed newMessage:", newMessage);
addOrUpdateMessages([newMessage]);
} catch (error) {
console.log("SSE: couldn't parse:", event.data);
console.log("Error parsing message SSE", error);
}
},
onError(event) {
console.log("SSE Error:", event);
},
});
return () => {
unsubscribe();
useSSESubscription<TicketMessage>({
enabled: Boolean(token) && Boolean(ticketId),
url: `https://admin.pena.digital/heruvym/ticket?ticket=${ticketId}&Authorization=${token}`,
onNewData: addOrUpdateMessages,
onDisconnect: useCallback(() => {
clearMessageState();
};
}, [ticketId, token]);
useEffect(function attachScrollHandler() {
if (!chatBoxRef.current) return;
setIsPreventAutoscroll(false);
}, []),
marker: "ticket message"
});
const throttledScrollHandler = useMemo(() => throttle(() => {
const chatBox = chatBoxRef.current;
const scrollHandler = () => {
const scrollBottom = chatBox.scrollHeight - chatBox.scrollTop - chatBox.clientHeight;
const isPreventAutoscroll = scrollBottom > chatBox.clientHeight;
setIsPreventAutoscroll(isPreventAutoscroll);
if (!chatBox) return;
if (messagesFetchStateRef.current !== "idle") return;
const scrollBottom = chatBox.scrollHeight - chatBox.scrollTop - chatBox.clientHeight;
const isPreventAutoscroll = scrollBottom > chatBox.clientHeight * 20;
setIsPreventAutoscroll(isPreventAutoscroll);
if (chatBox.scrollTop < chatBox.clientHeight) {
incrementMessageApiPage();
}
};
if (fetchState !== "idle") return;
const throttledScrollHandler = throttle(scrollHandler, 200);
chatBox.addEventListener("scroll", throttledScrollHandler);
if (chatBox.scrollTop < chatBox.clientHeight) {
incrementMessageApiPage();
}
}, 200), [fetchState]);
return () => {
chatBox.removeEventListener("scroll", throttledScrollHandler);
};
}, []);
useEventListener("scroll", throttledScrollHandler, chatBoxRef);
useEffect(function scrollOnNewMessage() {
if (!chatBoxRef.current) return;
@ -124,18 +83,25 @@ export default function SupportChat() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [lastMessageId]);
useEffect(() => useMessageStore.subscribe(state => (messagesFetchStateRef.current = state.fetchState)), []);
function handleSendMessage() {
if (!ticket || !messageField) return;
sendTicketMessage({
ticket: ticket.id,
message: messageField,
lang: "ru",
files: [],
makeRequest({
url: "https://hub.pena.digital/heruvym/send",
method: "POST",
useToken: true,
body: {
ticket: ticket.id,
message: messageField,
lang: "ru",
files: [],
},
}).then(() => {
setMessageField("");
}).catch(error => {
const errorMessage = getMessageFromFetchError(error);
if (errorMessage) enqueueSnackbar(errorMessage);
});
setMessageField("");
}
function scrollToBottom(behavior?: ScrollBehavior) {

@ -1,13 +1,16 @@
import { CircularProgress, List, ListItem, Box, useTheme, Pagination } from "@mui/material";
import TicketCard from "./TicketCard";
import { setTicketApiPage, useTicketStore } from "@root/stores/tickets";
import { Ticket } from "@root/model/ticket";
import { Ticket } from "@frontend/kitui";
export default function TicketList() {
interface Props {
fetchState: "fetching" | "idle" | "all fetched";
}
export default function TicketList({ fetchState }: Props) {
const theme = useTheme();
const tickets = useTicketStore(state => state.tickets);
const ticketsFetchState = useTicketStore(state => state.fetchState);
const ticketCount = useTicketStore(state => state.ticketCount);
const ticketApiPage = useTicketStore(state => state.apiPage);
const ticketsPerPage = useTicketStore(state => state.ticketsPerPage);
@ -25,10 +28,11 @@ export default function TicketList() {
<List
sx={{
p: 0,
minHeight: "120px",
display: "flex",
flexDirection: "column",
gap: "40px",
opacity: ticketsFetchState === "fetching" ? 0.4 : 1,
opacity: fetchState === "fetching" ? 0.4 : 1,
transitionProperty: "opacity",
transitionDuration: "200ms",
}}
@ -43,13 +47,13 @@ export default function TicketList() {
/>
</ListItem>
))}
{ticketsFetchState === "fetching" && (
{fetchState === "fetching" && (
<Box
sx={{
position: "absolute",
width: "100%",
height: "100%",
minHeight: "80px",
minHeight: "120px",
display: "flex",
alignItems: "center",
justifyContent: "center",

@ -3,13 +3,13 @@ import SectionWrapper from "@components/SectionWrapper";
import ComplexNavText from "@root/components/ComplexNavText";
import { enqueueSnackbar } from "notistack";
import { setCustomTariffs, useCustomTariffsStore } from "@root/stores/customTariffs";
import { useEffect } from "react";
import { useCallback } from "react";
import Summary from "./Summary";
import { fetchCustomTariffs } from "@root/api/tariff";
import { getMessageFromFetchError } from "@root/utils/backendMessageHandler";
import { getMessageFromFetchError } from "@frontend/kitui";
import ComplexHeader from "@root/components/ComplexHeader";
import CustomTariffCard from "./CustomTariffCard";
import ArrowBackIcon from "@mui/icons-material/ArrowBack";
import { useCustomTariffs } from "@root/utils/hooks/useCustomTariffs";
export default function TariffConstructor() {
@ -17,15 +17,14 @@ export default function TariffConstructor() {
const upMd = useMediaQuery(theme.breakpoints.up("md"));
const customTariffs = useCustomTariffsStore(state => state.customTariffs);
useEffect(function getCustomTariffs() {
const controller = new AbortController();
fetchCustomTariffs(controller.signal).then(setCustomTariffs).catch(error => {
useCustomTariffs({
url: "https://admin.pena.digital/strator/privilege/service",
onNewUser: setCustomTariffs,
onError: useCallback(error => {
const errorMessage = getMessageFromFetchError(error, "Не удалось получить кастомные тарифы");
if (errorMessage) enqueueSnackbar(errorMessage);
});
return () => controller.abort();
}, []);
}, [])
});
return (
<SectionWrapper

@ -2,7 +2,6 @@ import { Box, Dialog, IconButton, Link, Typography, useMediaQuery, useTheme } fr
import CloseIcon from "@mui/icons-material/Close";
import { useLocation, useNavigate } from "react-router-dom";
import { useFormik } from "formik";
import { authStore } from "@stores/makeRequest";
import CustomButton from "@components/CustomButton";
import InputTextfield from "@components/InputTextfield";
import PenaLogo from "@components/PenaLogo";
@ -12,7 +11,8 @@ import { object, string } from "yup";
import { useEffect, useState } from "react";
import { LoginRequest, LoginResponse } from "@root/model/auth";
import { setUserId, useUserStore } from "@root/stores/user";
import { getMessageFromFetchError } from "@root/utils/backendMessageHandler";
import { getMessageFromFetchError } from "@frontend/kitui";
import { makeRequest } from "@frontend/kitui";
import { cardShadow } from "@root/utils/themes/shadow";
interface Values {
@ -36,7 +36,6 @@ export default function SigninDialog() {
const theme = useTheme();
const upMd = useMediaQuery(theme.breakpoints.up("md"));
const navigate = useNavigate();
const makeRequest = authStore((state) => state.makeRequest);
const location = useLocation();
const formik = useFormik<Values>({
initialValues,

@ -6,13 +6,13 @@ import CustomButton from "@components/CustomButton";
import InputTextfield from "@components/InputTextfield";
import PenaLogo from "@components/PenaLogo";
import { enqueueSnackbar } from "notistack";
import { authStore } from "@stores/makeRequest";
import { Link as RouterLink } from "react-router-dom";
import { object, ref, string } from "yup";
import { useEffect, useState } from "react";
import { RegisterRequest, RegisterResponse } from "@root/model/auth";
import { setUserId, useUserStore } from "@root/stores/user";
import { getMessageFromFetchError } from "@root/utils/backendMessageHandler";
import { getMessageFromFetchError } from "@frontend/kitui";
import { makeRequest } from "@frontend/kitui";
import { cardShadow } from "@root/utils/themes/shadow";
@ -40,7 +40,6 @@ export default function SignupDialog() {
const theme = useTheme();
const upMd = useMediaQuery(theme.breakpoints.up("md"));
const navigate = useNavigate();
const makeRequest = authStore(state => state.makeRequest);
const location = useLocation();
const formik = useFormik<Values>({
initialValues,

24
src/stores/auth.ts Normal file

@ -0,0 +1,24 @@
import { create } from "zustand";
import { persist } from "zustand/middleware";
interface AuthStore {
token: string;
}
export const useAuthStore = create<AuthStore>()(
persist(
(set, get) => ({
token: "",
}),
{
name: "token",
}
)
);
export const getToken = () => useAuthStore.getState().token;
export const setToken = (token: string) => useAuthStore.setState({ token });
export const clearToken = () => useAuthStore.setState({ token: "" });

@ -1,106 +0,0 @@
import axios, { AxiosResponse } from "axios";
import { create } from "zustand";
import { persist } from "zustand/middleware";
type Token = string;
interface AuthStore {
token: Token;
makeRequest: <TRequest, TResponse>(props: FirstRequest<TRequest>) => Promise<TResponse>;
clearToken: () => void;
}
interface FirstRequest<T> {
method?: string;
url: string;
body?: T;
/** Send access token */
useToken?: boolean;
contentType?: boolean;
signal?: AbortSignal;
/** Send refresh token */
withCredentials?: boolean;
}
export const authStore = create<AuthStore>()(
persist(
(set, get) => ({
token: "",
makeRequest: <TRequest, TResponse>(props: FirstRequest<TRequest>): Promise<TResponse> => {
const newProps = { ...props, HC: (newToken: Token) => set({ token: newToken }), token: get().token };
return makeRequest<TRequest, TResponse>(newProps);
},
clearToken: () => set({ token: "" }),
}),
{
name: "token",
}
)
);
interface MakeRequest<T> extends FirstRequest<T> {
HC: (newToken: Token) => void;
token: Token;
}
async function makeRequest<TRequest, TResponse>({
method = "post",
url,
body,
useToken = true,
contentType = false,
HC,
token,
signal,
withCredentials,
}: MakeRequest<TRequest>) {
//В случае 401 рефреш должен попробовать вызваться 1 раз
let headers: Record<string, string> = {};
if (useToken) headers["Authorization"] = `Bearer ${token}`;
if (contentType) headers["Content-Type"] = "application/json";
try {
const response = await axios<TRequest, AxiosResponse<TResponse & { accessToken?: string }>>({
url,
method,
headers,
data: body,
signal,
withCredentials,
});
if (response.data?.accessToken) {
HC(response.data.accessToken);
}
return response.data;
} catch (error) {
if (axios.isAxiosError(error) && error.response?.status === 401 && !withCredentials) {
const refreshResponse = await refresh(token);
if (refreshResponse.data?.accessToken) HC(refreshResponse.data.accessToken);
headers["Authorization"] = refreshResponse.data.accessToken;
const response = await axios.request<TRequest, AxiosResponse<TResponse>>({
url,
method,
headers,
data: body,
signal,
});
return response.data;
}
throw error;
}
}
function refresh(token: Token) {
return axios<never, AxiosResponse<{ accessToken: string }>>("https://admin.pena.digital/auth/refresh", {
headers: {
Authorization: token,
"Content-Type": "application/json",
},
});
}

@ -1,11 +1,10 @@
import { TicketMessage } from "@root/model/ticket";
import { TicketMessage } from "@frontend/kitui";
import { create } from "zustand";
import { devtools } from "zustand/middleware";
interface MessageStore {
messages: TicketMessage[];
fetchState: "idle" | "fetching" | "all fetched";
apiPage: number;
messagesPerPage: number;
lastMessageId: string | undefined;
@ -17,7 +16,6 @@ export const useMessageStore = create<MessageStore>()(
(set, get) => ({
messages: [],
messagesPerPage: 10,
fetchState: "idle",
apiPage: 0,
lastMessageId: undefined,
isPreventAutoscroll: false,
@ -46,11 +44,8 @@ export const clearMessageState = () => useMessageStore.setState({
messages: [],
apiPage: 0,
lastMessageId: undefined,
fetchState: "idle",
});
export const setMessageFetchState = (fetchState: MessageStore["fetchState"]) => useMessageStore.setState({ fetchState });
export const incrementMessageApiPage = () => {
const state = useMessageStore.getState();

@ -1,4 +1,4 @@
import { Ticket } from "@root/model/ticket";
import { Ticket } from "@frontend/kitui";
import { create } from "zustand";
import { devtools } from "zustand/middleware";
@ -6,7 +6,6 @@ import { devtools } from "zustand/middleware";
interface TicketStore {
ticketCount: number;
tickets: Ticket[];
fetchState: "idle" | "fetching" | "all fetched";
apiPage: number;
ticketsPerPage: number;
}
@ -28,8 +27,6 @@ export const useTicketStore = create<TicketStore>()(
export const setTicketCount = (ticketCount: number) => useTicketStore.setState({ ticketCount });
export const setTicketsFetchState = (fetchState: TicketStore["fetchState"]) => useTicketStore.setState({ fetchState });
export const setTicketApiPage = (apiPage: number) => useTicketStore.setState({ apiPage: apiPage });
export const updateTickets = (receivedTickets: Ticket[]) => {

@ -1,4 +1,4 @@
import { TicketMessage } from "@root/model/ticket";
import { TicketMessage } from "@frontend/kitui";
import { create } from "zustand";
import { createJSONStorage, devtools, persist } from "zustand/middleware";
@ -10,7 +10,6 @@ interface UnauthTicketStore {
} | null;
isMessageSending: boolean;
messages: TicketMessage[];
fetchState: "idle" | "fetching" | "all fetched";
apiPage: number;
messagesPerPage: number;
lastMessageId: string | undefined;
@ -24,7 +23,6 @@ export const useUnauthTicketStore = create<UnauthTicketStore>()(
sessionData: null,
isMessageSending: false,
messages: [],
fetchState: "idle",
apiPage: 0,
messagesPerPage: 10,
lastMessageId: undefined,
@ -62,8 +60,6 @@ export const addOrUpdateUnauthMessages = (receivedMessages: TicketMessage[]) =>
});
};
export const setUnauthTicketFetchState = (fetchState: UnauthTicketStore["fetchState"]) => useUnauthTicketStore.setState({ fetchState });
export const incrementUnauthMessageApiPage = () => {
const state = useUnauthTicketStore.getState();

@ -1,28 +0,0 @@
import { isAxiosError } from "axios";
const backendErrorMessage: Record<string, string> = {
"user not found": "Пользователь не найден",
"invalid password": "Неправильный пароль",
"field <password> is empty": "Поле \"Пароль\" не заполнено",
"field <login> is empty": "Поле \"Логин\" не заполнено",
"field <email> is empty": "Поле \"E-mail\" не заполнено",
"field <phoneNumber> is empty": "Поле \"Номер телефона\" не заполнено",
"user with this email or login is exist": "Пользователь уже существует",
};
export function getMessageFromFetchError(error: any, defaultMessage?: string): string | null {
if (process.env.NODE_ENV !== "production") console.log(error);
const message = backendErrorMessage[error.response?.data?.message];
if (message) return message;
if (isAxiosError(error)) {
switch (error.code) {
case "ERR_NETWORK": return "Ошибка сети";
case "ERR_CANCELED": return null;
}
}
return defaultMessage ?? "Что-то пошло не так. Повторите попытку позже";
}

@ -1,29 +0,0 @@
type ThrottledFunction<T extends (...args: any) => any> = (...args: Parameters<T>) => void;
export function throttle<T extends (...args: any) => any>(func: T, ms: number): ThrottledFunction<T> {
let isThrottled = false;
let savedArgs: Parameters<T> | null;
let savedThis: any;
function wrapper(this: any, ...args: Parameters<T>) {
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;
}

@ -0,0 +1,27 @@
import { devlog } from "@frontend/kitui";
import { CustomTariffsMap } from "@root/model/customTariffs";
import axios, { AxiosResponse } from "axios";
import { useEffect } from "react";
export function useCustomTariffs({ onError, onNewUser, url }: {
url: string;
onNewUser: (response: CustomTariffsMap) => void;
onError: (error: any) => void;
}) {
useEffect(function fetchUserData() {
const controller = new AbortController();
axios<never, AxiosResponse<CustomTariffsMap>>({
url,
signal: controller.signal,
}).then(result => {
onNewUser(result.data);
}).catch(error => {
devlog("Error fetching custom tariffs", error);
onError(error);
});
return () => controller.abort();
}, [onError, onNewUser, url]);
}

@ -1,15 +0,0 @@
import { useState, useEffect } from "react";
export function useDebounce<T>(value: T, delay: number) {
const [debouncedValue, setDebouncedValue] = useState<T>(value);
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => clearTimeout(handler);
}, [value, delay]);
return debouncedValue;
}

@ -1,22 +0,0 @@
import { useState, useEffect, useRef } from "react";
export function useThrottle<T>(value: T, delay: number) {
const [throttledValue, setThrottledValue] = useState<T>(value);
const time = useRef<number>(0);
useEffect(() => {
const now = Date.now();
if (now > time.current + delay) {
time.current = now;
setThrottledValue(value);
} else {
const handler = setTimeout(() => {
setThrottledValue(value);
}, delay);
return () => clearTimeout(handler);
}
}, [value, delay]);
return throttledValue;
}

@ -0,0 +1,33 @@
import { devlog, makeRequest } from "@frontend/kitui";
import { User } from "@root/model/user";
import { useEffect } from "react";
export function useUser({ onError, onNewUser, url, userId }: {
url: string;
userId: string | null;
onNewUser: (response: User) => void;
onError: (error: any) => void;
}) {
useEffect(function fetchUserData() {
if (!userId) return;
const controller = new AbortController();
makeRequest<never, User>({
url,
contentType: true,
method: "GET",
useToken: false,
withCredentials: false,
signal: controller.signal,
}).then(result => {
onNewUser(result);
}).catch(error => {
devlog("Error fetching user", error);
onError(error);
});
return () => controller.abort();
}, [onError, onNewUser, url, userId]);
}

@ -1,16 +1,16 @@
import { useAuthStore } from "@root/stores/auth";
import * as React from "react";
import { useLocation, Navigate } from "react-router-dom";
import { authStore } from "@stores/makeRequest";
type Props = { children: JSX.Element };
type Props = { children: JSX.Element; };
export default ({ children }: Props) => {
const location = useLocation();
const { token } = authStore();
console.log(token);
//Если пользователь авторизован, перенаправляем его в приложение. Иначе пускаем куда хотел
if (token) {
return <Navigate to="/settings" state={{ from: location }} />;
}
return children;
const location = useLocation();
const token = useAuthStore(state => state.token);
console.log(token);
//Если пользователь авторизован, перенаправляем его в приложение. Иначе пускаем куда хотел
if (token) {
return <Navigate to="/settings" state={{ from: location }} />;
}
return children;
};

@ -1450,6 +1450,13 @@
minimatch "^3.1.2"
strip-json-comments "^3.1.1"
"@frontend/kitui@^1.0.6":
version "1.0.6"
resolved "https://penahub.gitlab.yandexcloud.net/api/v4/projects/21/packages/npm/@frontend/kitui/-/@frontend/kitui-1.0.6.tgz#0ac4b0e76163de95af3668930e7682a021e83366"
integrity sha1-CsSw52Fj3pWvNmiTDnaCoCHoM2Y=
dependencies:
reconnecting-eventsource "^1.6.2"
"@humanwhocodes/config-array@^0.11.6":
version "0.11.7"
resolved "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.7.tgz"
@ -3234,7 +3241,7 @@ async@^3.2.3:
asynckit@^0.4.0:
version "0.4.0"
resolved "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz"
resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79"
integrity sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==
at-least-node@^1.0.0:
@ -3797,7 +3804,7 @@ colorette@^2.0.10:
combined-stream@^1.0.8:
version "1.0.8"
resolved "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz"
resolved "https://registry.yarnpkg.com/combined-stream/-/combined-stream-1.0.8.tgz#c3d45a8b34fd730631a110a8a2520682b31d5a7f"
integrity sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==
dependencies:
delayed-stream "~1.0.0"
@ -4278,7 +4285,7 @@ defined@^1.0.0:
delayed-stream@~1.0.0:
version "1.0.0"
resolved "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz"
resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619"
integrity sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==
depd@2.0.0:
@ -5180,7 +5187,7 @@ flatted@^3.1.0:
follow-redirects@^1.0.0, follow-redirects@^1.15.0:
version "1.15.2"
resolved "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.2.tgz"
resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.2.tgz#b460864144ba63f2681096f274c4e57026da2c13"
integrity sha512-VQLG33o04KaQ8uYi2tVNbdrWp1QWxNNea+nmIB4EVM28v0hmP17z7aG1+wAkNzVq4KeXTq3221ye5qTJP91JwA==
for-each@^0.3.3:
@ -5220,7 +5227,7 @@ form-data@^3.0.0:
form-data@^4.0.0:
version "4.0.0"
resolved "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz"
resolved "https://registry.yarnpkg.com/form-data/-/form-data-4.0.0.tgz#93919daeaf361ee529584b9b31664dc12c9fa452"
integrity sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==
dependencies:
asynckit "^0.4.0"
@ -7341,12 +7348,12 @@ micromatch@^4.0.2, micromatch@^4.0.4, micromatch@^4.0.5:
mime-db@1.52.0, "mime-db@>= 1.43.0 < 2":
version "1.52.0"
resolved "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz"
resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.52.0.tgz#bbabcdc02859f4987301c856e3387ce5ec43bf70"
integrity sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==
mime-types@^2.1.12, mime-types@^2.1.27, mime-types@^2.1.31, mime-types@~2.1.17, mime-types@~2.1.24, mime-types@~2.1.34:
version "2.1.35"
resolved "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz"
resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.35.tgz#381a871b62a734450660ae3deee44813f70d959a"
integrity sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==
dependencies:
mime-db "1.52.0"
@ -8504,7 +8511,7 @@ proxy-addr@~2.0.7:
proxy-from-env@^1.1.0:
version "1.1.0"
resolved "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz"
resolved "https://registry.yarnpkg.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz#e102f16ca355424865755d2c9e8ea4f24d58c3e2"
integrity sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==
psl@^1.1.33:
@ -8783,7 +8790,7 @@ readdirp@~3.6.0:
reconnecting-eventsource@^1.6.2:
version "1.6.2"
resolved "https://registry.npmjs.org/reconnecting-eventsource/-/reconnecting-eventsource-1.6.2.tgz"
resolved "https://registry.yarnpkg.com/reconnecting-eventsource/-/reconnecting-eventsource-1.6.2.tgz#b7f5b03b1c76291f6fbcb0203004892a57ae253b"
integrity sha512-vHhoxVLbA2YcfljWMKEbgR1KVTgwIrnyh/bzVJc+gfQbGcUIToLL6jNhkUL4E+9FbnAcfUVNLIw2YCiliTg/4g==
recursive-readdir@^2.2.2: