use package

This commit is contained in:
nflnkr 2023-06-06 16:13:58 +03:00
parent 5a340d6be6
commit 7faf0cc4b3
32 changed files with 323 additions and 22427 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": { "dependencies": {
"@emotion/react": "^11.10.5", "@emotion/react": "^11.10.5",
"@emotion/styled": "^11.10.5", "@emotion/styled": "^11.10.5",
"@frontend/kitui": "^1.0.2",
"@mui/icons-material": "^5.10.14", "@mui/icons-material": "^5.10.14",
"@mui/material": "^5.10.14", "@mui/material": "^5.10.14",
"axios": "^1.3.4", "axios": "^1.3.4",

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

@ -1,12 +0,0 @@
import { CustomTariffsMap } from "@root/model/customTariffs";
import axios, { AxiosResponse } from "axios";
export async function fetchCustomTariffs(signal: AbortSignal) {
const response = await axios<never, AxiosResponse<CustomTariffsMap>>({
url: "https://admin.pena.digital/strator/privilege/service",
signal,
});
return response.data;
}

5
src/api/makeRequest.ts Normal file

@ -0,0 +1,5 @@
import { createMakeRequest } from "@frontend/kitui";
import { getToken, setToken } from "@root/stores/auth";
export const makeRequest = createMakeRequest(getToken, setToken);

@ -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 { PatchUserRequest, User } from "@root/model/user"; import { PatchUserRequest, User } from "@root/model/user";
import { authStore } from "@root/stores/makeRequest"; import { makeRequest } from "./makeRequest";
const apiUrl = process.env.NODE_ENV === "production" ? "" : "https://hub.pena.digital"; 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) { export function patchUser(user: PatchUserRequest) {
return makeRequest<PatchUserRequest, User>({ return makeRequest<PatchUserRequest, User>({
url: apiUrl + "/user/", url: apiUrl + "/user/",

@ -1,14 +1,14 @@
import { Box, FormControl, IconButton, InputAdornment, InputBase, SxProps, Theme, Typography, useMediaQuery, useTheme } from "@mui/material"; import { Box, FormControl, IconButton, InputAdornment, InputBase, SxProps, Theme, Typography, useMediaQuery, useTheme } from "@mui/material";
import { createTicket, getUnauthTicketMessages, sendTicketMessage, subscribeToUnauthTicketMessages } from "@root/api/tickets"; import { TicketMessage } from "@frontend/kitui";
import { GetMessagesRequest, TicketMessage } from "@root/model/ticket"; import { addOrUpdateUnauthMessages, useUnauthTicketStore, incrementUnauthMessageApiPage, setUnauthIsPreventAutoscroll, setUnauthSessionData, setIsMessageSending } from "@root/stores/unauthTicket";
import { addOrUpdateUnauthMessages, setUnauthTicketFetchState, useUnauthTicketStore, incrementUnauthMessageApiPage, setUnauthIsPreventAutoscroll, setUnauthSessionData, setIsMessageSending } from "@root/stores/unauthTicket";
import { enqueueSnackbar } from "notistack"; import { enqueueSnackbar } from "notistack";
import { useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import ChatMessage from "../ChatMessage"; import ChatMessage from "../ChatMessage";
import SendIcon from "../icons/SendIcon"; import SendIcon from "../icons/SendIcon";
import UserCircleIcon from "./UserCircleIcon"; import UserCircleIcon from "./UserCircleIcon";
import { throttle } from "@root/utils/decorators"; import { throttle } from "@frontend/kitui";
import { getMessageFromFetchError } from "@root/utils/backendMessageHandler"; import { makeRequest } from "@root/api/makeRequest";
import { useTicketMessages, getMessageFromFetchError, useSSESubscription, useEventListener, createTicket } from "@frontend/kitui";
interface Props { interface Props {
@ -24,93 +24,53 @@ export default function Chat({ sx }: Props) {
const messageApiPage = useUnauthTicketStore(state => state.apiPage); const messageApiPage = useUnauthTicketStore(state => state.apiPage);
const messagesPerPage = useUnauthTicketStore(state => state.messagesPerPage); const messagesPerPage = useUnauthTicketStore(state => state.messagesPerPage);
const isMessageSending = useUnauthTicketStore(state => state.isMessageSending); const isMessageSending = useUnauthTicketStore(state => state.isMessageSending);
const messagesFetchStateRef = useRef(useUnauthTicketStore.getState().fetchState);
const isPreventAutoscroll = useUnauthTicketStore(state => state.isPreventAutoscroll); const isPreventAutoscroll = useUnauthTicketStore(state => state.isPreventAutoscroll);
const lastMessageId = useUnauthTicketStore(state => state.lastMessageId); const lastMessageId = useUnauthTicketStore(state => state.lastMessageId);
const chatBoxRef = useRef<HTMLDivElement>(); const chatBoxRef = useRef<HTMLDivElement>(null);
useEffect(function fetchTicketMessages() { const fetchState = useTicketMessages({
if (!sessionData) return; makeRequest,
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 = { useSSESubscription<TicketMessage>({
amt: messagesPerPage, enabled: Boolean(sessionData),
page: messageApiPage, url: `https://hub.pena.digital/heruvym/ticket?ticket=${sessionData?.ticketId}&s=${sessionData?.sessionId}`,
ticket: sessionData.ticketId, onNewData: addOrUpdateUnauthMessages,
}; onDisconnect: useCallback(() => {
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();
setUnauthIsPreventAutoscroll(false); setUnauthIsPreventAutoscroll(false);
}; }, []),
}, [sessionData]); marker: "ticket"
});
useEffect(function attachScrollHandler() {
if (!chatBoxRef.current) return;
const throttledScrollHandler = useMemo(() => throttle(() => {
const chatBox = chatBoxRef.current; const chatBox = chatBoxRef.current;
const scrollHandler = () => { if (!chatBox) return;
const scrollBottom = chatBox.scrollHeight - chatBox.scrollTop - chatBox.clientHeight;
const isPreventAutoscroll = scrollBottom > chatBox.clientHeight;
setUnauthIsPreventAutoscroll(isPreventAutoscroll);
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) { if (fetchState !== "idle") return;
incrementUnauthMessageApiPage();
}
};
const throttledScrollHandler = throttle(scrollHandler, 200); if (chatBox.scrollTop < chatBox.clientHeight) {
chatBox.addEventListener("scroll", throttledScrollHandler); incrementUnauthMessageApiPage();
}
}, 200), [fetchState]);
return () => { useEventListener("scroll", throttledScrollHandler, chatBoxRef);
chatBox.removeEventListener("scroll", throttledScrollHandler);
};
}, []);
useEffect(function scrollOnNewMessage() { useEffect(function scrollOnNewMessage() {
if (!chatBoxRef.current) return; if (!chatBoxRef.current) return;
@ -123,17 +83,20 @@ export default function Chat({ sx }: Props) {
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [lastMessageId]); }, [lastMessageId]);
useEffect(() => useUnauthTicketStore.subscribe(state => (messagesFetchStateRef.current = state.fetchState)), []);
async function handleSendMessage() { async function handleSendMessage() {
if (!messageField || isMessageSending) return; if (!messageField || isMessageSending) return;
if (!sessionData) { if (!sessionData) {
setIsMessageSending(true); setIsMessageSending(true);
createTicket({ createTicket({
Title: "Unauth title", makeRequest,
Message: messageField, url: "https://hub.pena.digital/heruvym/create",
}, false).then(response => { body: {
Title: "Unauth title",
Message: messageField,
},
useToken: false,
}).then(response => {
setUnauthSessionData({ setUnauthSessionData({
ticketId: response.Ticket, ticketId: response.Ticket,
sessionId: response.sess, sessionId: response.sess,
@ -147,12 +110,18 @@ export default function Chat({ sx }: Props) {
}); });
} else { } else {
setIsMessageSending(true); setIsMessageSending(true);
sendTicketMessage({ makeRequest({
ticket: sessionData.ticketId, url: "https://hub.pena.digital/heruvym/send",
message: messageField, method: "POST",
lang: "ru", useToken: false,
files: [], body: {
}, true).catch(error => { ticket: sessionData.ticketId,
message: messageField,
lang: "ru",
files: [],
},
withCredentials: true,
}).catch(error => {
const errorMessage = getMessageFromFetchError(error); const errorMessage = getMessageFromFetchError(error);
if (errorMessage) enqueueSnackbar(errorMessage); if (errorMessage) enqueueSnackbar(errorMessage);
}).finally(() => { }).finally(() => {

@ -4,7 +4,6 @@ import { Box, Button, Container, IconButton, Typography, useTheme } from "@mui/m
import SectionWrapper from "../SectionWrapper"; import SectionWrapper from "../SectionWrapper";
import { basketStore } from "@stores/BasketStore"; import { basketStore } from "@stores/BasketStore";
import { authStore } from "@stores/makeRequest";
import LogoutIcon from "../icons/LogoutIcon"; import LogoutIcon from "../icons/LogoutIcon";
import WalletIcon from "../icons/WalletIcon"; import WalletIcon from "../icons/WalletIcon";
@ -15,7 +14,8 @@ import Menu from "../Menu";
import { logout } from "@root/api/auth"; import { logout } from "@root/api/auth";
import { enqueueSnackbar } from "notistack"; import { enqueueSnackbar } from "notistack";
import { clearUser, useUserStore } from "@root/stores/user"; 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 { interface Props {
isLoggedIn: boolean; isLoggedIn: boolean;
@ -23,7 +23,6 @@ interface Props {
export default function NavbarFull({ isLoggedIn }: Props) { export default function NavbarFull({ isLoggedIn }: Props) {
const theme = useTheme(); const theme = useTheme();
const { clearToken } = authStore();
const location = useLocation(); const location = useLocation();
const navigate = useNavigate(); const navigate = useNavigate();
const user = useUserStore((state) => state.user); 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 ReactDOM from "react-dom/client";
import { BrowserRouter, Navigate, Route, Routes, useLocation } from "react-router-dom"; import { BrowserRouter, Navigate, Route, Routes, useLocation } from "react-router-dom";
import { CssBaseline, ThemeProvider } from "@mui/material"; import { CssBaseline, ThemeProvider } from "@mui/material";
@ -20,26 +20,26 @@ import reportWebVitals from "./reportWebVitals";
import { SnackbarProvider, enqueueSnackbar } from "notistack"; import { SnackbarProvider, enqueueSnackbar } from "notistack";
import "./index.css"; import "./index.css";
import Layout from "./components/Layout"; import Layout from "./components/Layout";
import { getUser } from "./api/user";
import { setUser, useUserStore } from "./stores/user"; import { setUser, useUserStore } from "./stores/user";
import TariffConstructor from "./pages/TariffConstructor/TariffConstructor"; import TariffConstructor from "./pages/TariffConstructor/TariffConstructor";
import { useUser } from "./utils/hooks/useUser";
import { makeRequest } from "./api/makeRequest";
const App = () => { const App = () => {
const location = useLocation(); const location = useLocation();
const userId = useUserStore(state => state.userId); const userId = useUserStore(state => state.userId);
useEffect(function fetchUserData() { useUser({
if (!userId) return; makeRequest,
url: `https://hub.pena.digital/user/${userId}`,
getUser(userId).then(result => { userId,
setUser(result); onNewUser: setUser,
}).catch(error => { onError: useCallback(error => {
console.log("Error fetching user", error);
enqueueSnackbar(error.response?.data?.message ?? error.message ?? "Error fetching user"); enqueueSnackbar(error.response?.data?.message ?? error.message ?? "Error fetching user");
setUser(null); setUser(null);
}); }, [])
}, [userId]); });
if (location.state?.redirectTo === "/signin") return <Navigate to="/signin" replace state={{ backgroundLocation: location }} />; if (location.state?.redirectTo === "/signin") return <Navigate to="/signin" replace state={{ backgroundLocation: location }} />;
if (location.state?.redirectTo === "/signup") return <Navigate to="/signup" replace state={{ backgroundLocation: location }} />; if (location.state?.redirectTo === "/signup") return <Navigate to="/signup" 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,8 +2,9 @@ import { Box, Typography, FormControl, InputBase, useMediaQuery, useTheme } from
import { useState } from "react"; import { useState } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import CustomButton from "@components/CustomButton"; import CustomButton from "@components/CustomButton";
import { createTicket } from "@root/api/tickets";
import { enqueueSnackbar } from "notistack"; import { enqueueSnackbar } from "notistack";
import { makeRequest } from "@root/api/makeRequest";
import { createTicket } from "@frontend/kitui";
export default function CreateTicket() { export default function CreateTicket() {
@ -17,8 +18,12 @@ export default function CreateTicket() {
if (!ticketBodyField || !ticketNameField) return; if (!ticketBodyField || !ticketNameField) return;
createTicket({ createTicket({
Title: ticketNameField, makeRequest,
Message: ticketBodyField, url: "https://hub.pena.digital/heruvym/create",
body: {
Title: ticketNameField,
Message: ticketBodyField,
}
}).then(result => { }).then(result => {
navigate(`/support/${result.Ticket}`); navigate(`/support/${result.Ticket}`);
}).catch(error => { }).catch(error => {

@ -6,12 +6,13 @@ import ComplexNavText from "@components/ComplexNavText";
import SupportChat from "./SupportChat"; import SupportChat from "./SupportChat";
import CreateTicket from "./CreateTicket"; import CreateTicket from "./CreateTicket";
import TicketList from "./TicketList/TicketList"; import TicketList from "./TicketList/TicketList";
import { useEffect } from "react"; import { useCallback } from "react";
import { getTickets, subscribeToAllTickets } from "@root/api/tickets"; import { Ticket } from "@frontend/kitui";
import { GetTicketsRequest, Ticket } from "@root/model/ticket"; import { updateTickets, setTicketCount, clearTickets, useTicketStore } from "@root/stores/tickets";
import { updateTickets, setTicketCount, clearTickets, useTicketStore, setTicketsFetchState } from "@root/stores/tickets";
import { enqueueSnackbar } from "notistack"; import { enqueueSnackbar } from "notistack";
import { authStore } from "@root/stores/makeRequest"; import { useAuthStore } from "@root/stores/auth";
import { makeRequest } from "@root/api/makeRequest";
import { useSSESubscription, useTickets } from "@frontend/kitui";
export default function Support() { export default function Support() {
@ -20,61 +21,31 @@ export default function Support() {
const ticketId = useParams().ticketId; const ticketId = useParams().ticketId;
const ticketApiPage = useTicketStore(state => state.apiPage); const ticketApiPage = useTicketStore(state => state.apiPage);
const ticketsPerPage = useTicketStore(state => state.ticketsPerPage); const ticketsPerPage = useTicketStore(state => state.ticketsPerPage);
const token = authStore(state => state.token); const token = useAuthStore(state => state.token);
useEffect(function fetchTickets() { const fetchState = useTickets({
const getTicketsBody: GetTicketsRequest = { makeRequest,
amt: ticketsPerPage, url: "https://hub.pena.digital/heruvym/getTickets",
page: ticketApiPage, ticketsPerPage,
status: "open", ticketApiPage,
}; onNewTickets: useCallback(result => {
const controller = new AbortController(); if (result.data) updateTickets(result.data);
setTicketCount(result.count);
setTicketsFetchState("fetching"); }, []),
getTickets({ onError: useCallback((error: Error) => {
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);
enqueueSnackbar(error.message); enqueueSnackbar(error.message);
}); }, [])
});
return () => controller.abort(); useSSESubscription<Ticket>({
}, [ticketApiPage, ticketsPerPage]); enabled: Boolean(token),
url: `https://admin.pena.digital/heruvym/subscribe?Authorization=${token}`,
useEffect(function subscribeToTickets() { onNewData: updateTickets,
if (!token) return; onDisconnect: useCallback(() => {
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();
clearTickets(); clearTickets();
}; }, []),
}, [token]); marker: "ticket"
});
return ( return (
<SectionWrapper <SectionWrapper
@ -112,7 +83,7 @@ export default function Support() {
}} }}
> >
<CreateTicket /> <CreateTicket />
<TicketList /> <TicketList fetchState={fetchState} />
</Box> </Box>
)} )}
</SectionWrapper> </SectionWrapper>

@ -1,17 +1,18 @@
import { Box, Fab, FormControl, IconButton, InputAdornment, InputBase, Typography, useMediaQuery, useTheme, } from "@mui/material"; import { Box, Fab, FormControl, IconButton, InputAdornment, InputBase, Typography, useMediaQuery, useTheme, } from "@mui/material";
import ArrowDownwardIcon from "@mui/icons-material/ArrowDownward"; 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 { useParams } from "react-router-dom";
import CustomButton from "@components/CustomButton"; import CustomButton from "@components/CustomButton";
import SendIcon from "@components/icons/SendIcon"; import SendIcon from "@components/icons/SendIcon";
import { throttle } from "@utils/decorators"; import { throttle } from "@frontend/kitui";
import { enqueueSnackbar } from "notistack"; import { enqueueSnackbar } from "notistack";
import { useTicketStore } from "@root/stores/tickets"; import { useTicketStore } from "@root/stores/tickets";
import { addOrUpdateMessages, clearMessageState, incrementMessageApiPage, setIsPreventAutoscroll, setMessageFetchState, useMessageStore } from "@root/stores/messages"; import { addOrUpdateMessages, clearMessageState, incrementMessageApiPage, setIsPreventAutoscroll, useMessageStore } from "@root/stores/messages";
import { getTicketMessages, sendTicketMessage, subscribeToTicketMessages } from "@root/api/tickets"; import { TicketMessage } from "@frontend/kitui";
import { GetMessagesRequest, TicketMessage } from "@root/model/ticket";
import { authStore } from "@root/stores/makeRequest";
import ChatMessage from "@root/components/ChatMessage"; import ChatMessage from "@root/components/ChatMessage";
import { useAuthStore } from "@root/stores/auth";
import { makeRequest } from "@root/api/makeRequest";
import { getMessageFromFetchError, useEventListener, useSSESubscription, useTicketMessages } from "@frontend/kitui";
export default function SupportChat() { export default function SupportChat() {
@ -24,93 +25,53 @@ export default function SupportChat() {
const lastMessageId = useMessageStore(state => state.lastMessageId); const lastMessageId = useMessageStore(state => state.lastMessageId);
const messagesPerPage = useMessageStore(state => state.messagesPerPage); const messagesPerPage = useMessageStore(state => state.messagesPerPage);
const isPreventAutoscroll = useMessageStore(state => state.isPreventAutoscroll); const isPreventAutoscroll = useMessageStore(state => state.isPreventAutoscroll);
const messagesFetchStateRef = useRef(useMessageStore.getState().fetchState); const token = useAuthStore(state => state.token);
const token = authStore(state => state.token);
const ticketId = useParams().ticketId; const ticketId = useParams().ticketId;
const ticket = tickets.find(ticket => ticket.id === ticketId); const ticket = tickets.find(ticket => ticket.id === ticketId);
const chatBoxRef = useRef<HTMLDivElement>(); const chatBoxRef = useRef<HTMLDivElement>(null);
useEffect(function fetchTicketMessages() { const fetchState = useTicketMessages({
if (!ticketId) return; makeRequest,
url: "https://admin.pena.digital/heruvym/getMessages",
const getTicketsBody: GetMessagesRequest = { ticketId,
amt: messagesPerPage, messagesPerPage,
page: messageApiPage, messageApiPage,
ticket: ticketId, onNewMessages: useCallback(messages => {
}; if (chatBoxRef.current && chatBoxRef.current.scrollTop < 1) chatBoxRef.current.scrollTop = 1;
const controller = new AbortController(); addOrUpdateMessages(messages);
}, []),
setMessageFetchState("fetching"); onError: useCallback((error: Error) => {
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);
enqueueSnackbar(error.message); enqueueSnackbar(error.message);
}); }, []),
});
return () => { useSSESubscription<TicketMessage>({
controller.abort(); enabled: Boolean(token) && Boolean(ticketId),
}; url: `https://admin.pena.digital/heruvym/ticket?ticket=${ticketId}&Authorization=${token}`,
}, [messageApiPage, messagesPerPage, ticketId]); onNewData: addOrUpdateMessages,
onDisconnect: useCallback(() => {
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();
clearMessageState(); clearMessageState();
}; setIsPreventAutoscroll(false);
}, [ticketId, token]); }, []),
marker: "ticket message"
useEffect(function attachScrollHandler() { });
if (!chatBoxRef.current) return;
const throttledScrollHandler = useMemo(() => throttle(() => {
const chatBox = chatBoxRef.current; const chatBox = chatBoxRef.current;
const scrollHandler = () => { if (!chatBox) return;
const scrollBottom = chatBox.scrollHeight - chatBox.scrollTop - chatBox.clientHeight;
const isPreventAutoscroll = scrollBottom > chatBox.clientHeight;
setIsPreventAutoscroll(isPreventAutoscroll);
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) { if (fetchState !== "idle") return;
incrementMessageApiPage();
}
};
const throttledScrollHandler = throttle(scrollHandler, 200); if (chatBox.scrollTop < chatBox.clientHeight) {
chatBox.addEventListener("scroll", throttledScrollHandler); incrementMessageApiPage();
}
}, 200), [fetchState]);
return () => { useEventListener("scroll", throttledScrollHandler, chatBoxRef);
chatBox.removeEventListener("scroll", throttledScrollHandler);
};
}, []);
useEffect(function scrollOnNewMessage() { useEffect(function scrollOnNewMessage() {
if (!chatBoxRef.current) return; if (!chatBoxRef.current) return;
@ -123,18 +84,25 @@ export default function SupportChat() {
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [lastMessageId]); }, [lastMessageId]);
useEffect(() => useMessageStore.subscribe(state => (messagesFetchStateRef.current = state.fetchState)), []);
function handleSendMessage() { function handleSendMessage() {
if (!ticket || !messageField) return; if (!ticket || !messageField) return;
sendTicketMessage({ makeRequest({
ticket: ticket.id, url: "https://hub.pena.digital/heruvym/send",
message: messageField, method: "POST",
lang: "ru", useToken: true,
files: [], 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) { function scrollToBottom(behavior?: ScrollBehavior) {

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

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

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

@ -6,13 +6,13 @@ import CustomButton from "@components/CustomButton";
import InputTextfield from "@components/InputTextfield"; import InputTextfield from "@components/InputTextfield";
import PenaLogo from "@components/PenaLogo"; import PenaLogo from "@components/PenaLogo";
import { enqueueSnackbar } from "notistack"; import { enqueueSnackbar } from "notistack";
import { authStore } from "@stores/makeRequest";
import { Link as RouterLink } from "react-router-dom"; import { Link as RouterLink } from "react-router-dom";
import { object, ref, string } from "yup"; import { object, ref, string } from "yup";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { RegisterRequest, RegisterResponse } from "@root/model/auth"; import { RegisterRequest, RegisterResponse } from "@root/model/auth";
import { setUserId, useUserStore } from "@root/stores/user"; import { setUserId, useUserStore } from "@root/stores/user";
import { getMessageFromFetchError } from "@root/utils/backendMessageHandler"; import { getMessageFromFetchError } from "@frontend/kitui";
import { makeRequest } from "@root/api/makeRequest";
interface Values { interface Values {
@ -39,7 +39,6 @@ export default function SignupDialog() {
const theme = useTheme(); const theme = useTheme();
const upMd = useMediaQuery(theme.breakpoints.up("md")); const upMd = useMediaQuery(theme.breakpoints.up("md"));
const navigate = useNavigate(); const navigate = useNavigate();
const makeRequest = authStore(state => state.makeRequest);
const location = useLocation(); const location = useLocation();
const formik = useFormik<Values>({ const formik = useFormik<Values>({
initialValues, 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 { create } from "zustand";
import { devtools } from "zustand/middleware"; import { devtools } from "zustand/middleware";
interface MessageStore { interface MessageStore {
messages: TicketMessage[]; messages: TicketMessage[];
fetchState: "idle" | "fetching" | "all fetched";
apiPage: number; apiPage: number;
messagesPerPage: number; messagesPerPage: number;
lastMessageId: string | undefined; lastMessageId: string | undefined;
@ -17,7 +16,6 @@ export const useMessageStore = create<MessageStore>()(
(set, get) => ({ (set, get) => ({
messages: [], messages: [],
messagesPerPage: 10, messagesPerPage: 10,
fetchState: "idle",
apiPage: 0, apiPage: 0,
lastMessageId: undefined, lastMessageId: undefined,
isPreventAutoscroll: false, isPreventAutoscroll: false,
@ -46,11 +44,8 @@ export const clearMessageState = () => useMessageStore.setState({
messages: [], messages: [],
apiPage: 0, apiPage: 0,
lastMessageId: undefined, lastMessageId: undefined,
fetchState: "idle",
}); });
export const setMessageFetchState = (fetchState: MessageStore["fetchState"]) => useMessageStore.setState({ fetchState });
export const incrementMessageApiPage = () => { export const incrementMessageApiPage = () => {
const state = useMessageStore.getState(); const state = useMessageStore.getState();

@ -1,4 +1,4 @@
import { Ticket } from "@root/model/ticket"; import { Ticket } from "@frontend/kitui";
import { create } from "zustand"; import { create } from "zustand";
import { devtools } from "zustand/middleware"; import { devtools } from "zustand/middleware";
@ -6,7 +6,6 @@ import { devtools } from "zustand/middleware";
interface TicketStore { interface TicketStore {
ticketCount: number; ticketCount: number;
tickets: Ticket[]; tickets: Ticket[];
fetchState: "idle" | "fetching" | "all fetched";
apiPage: number; apiPage: number;
ticketsPerPage: number; ticketsPerPage: number;
} }
@ -28,8 +27,6 @@ export const useTicketStore = create<TicketStore>()(
export const setTicketCount = (ticketCount: number) => useTicketStore.setState({ ticketCount }); 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 setTicketApiPage = (apiPage: number) => useTicketStore.setState({ apiPage: apiPage });
export const updateTickets = (receivedTickets: Ticket[]) => { 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 { create } from "zustand";
import { createJSONStorage, devtools, persist } from "zustand/middleware"; import { createJSONStorage, devtools, persist } from "zustand/middleware";
@ -10,7 +10,6 @@ interface UnauthTicketStore {
} | null; } | null;
isMessageSending: boolean; isMessageSending: boolean;
messages: TicketMessage[]; messages: TicketMessage[];
fetchState: "idle" | "fetching" | "all fetched";
apiPage: number; apiPage: number;
messagesPerPage: number; messagesPerPage: number;
lastMessageId: string | undefined; lastMessageId: string | undefined;
@ -24,7 +23,6 @@ export const useUnauthTicketStore = create<UnauthTicketStore>()(
sessionData: null, sessionData: null,
isMessageSending: false, isMessageSending: false,
messages: [], messages: [],
fetchState: "idle",
apiPage: 0, apiPage: 0,
messagesPerPage: 10, messagesPerPage: 10,
lastMessageId: undefined, lastMessageId: undefined,
@ -62,8 +60,6 @@ export const addOrUpdateUnauthMessages = (receivedMessages: TicketMessage[]) =>
}); });
}; };
export const setUnauthTicketFetchState = (fetchState: UnauthTicketStore["fetchState"]) => useUnauthTicketStore.setState({ fetchState });
export const incrementUnauthMessageApiPage = () => { export const incrementUnauthMessageApiPage = () => {
const state = useUnauthTicketStore.getState(); 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,34 @@
import { createMakeRequest, devlog } from "@frontend/kitui";
import { User } from "@root/model/user";
import { useEffect } from "react";
export function useUser({ makeRequest, onError, onNewUser, url, userId }: {
makeRequest: ReturnType<typeof createMakeRequest>;
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();
}, [makeRequest, onError, onNewUser, url, userId]);
}

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

@ -1450,6 +1450,14 @@
minimatch "^3.1.2" minimatch "^3.1.2"
strip-json-comments "^3.1.1" strip-json-comments "^3.1.1"
"@frontend/kitui@^1.0.2":
version "1.0.4"
resolved "https://penahub.gitlab.yandexcloud.net/api/v4/projects/21/packages/npm/@frontend/kitui/-/@frontend/kitui-1.0.4.tgz#32c842f5aeb6d71d2735cc8f3cad4f902c601dd9"
integrity sha1-MshC9a621x0nNcyPPK1PkCxgHdk=
dependencies:
axios "^1.4.0"
reconnecting-eventsource "^1.6.2"
"@humanwhocodes/config-array@^0.11.6": "@humanwhocodes/config-array@^0.11.6":
version "0.11.7" version "0.11.7"
resolved "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.7.tgz" resolved "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.7.tgz"
@ -3234,7 +3242,7 @@ async@^3.2.3:
asynckit@^0.4.0: asynckit@^0.4.0:
version "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== integrity sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==
at-least-node@^1.0.0: at-least-node@^1.0.0:
@ -3273,6 +3281,15 @@ axios@^1.3.4:
form-data "^4.0.0" form-data "^4.0.0"
proxy-from-env "^1.1.0" proxy-from-env "^1.1.0"
axios@^1.4.0:
version "1.4.0"
resolved "https://registry.yarnpkg.com/axios/-/axios-1.4.0.tgz#38a7bf1224cd308de271146038b551d725f0be1f"
integrity sha512-S4XCWMEmzvo64T9GfvQDOXgYRDJ/wsSZc7Jvdgx5u1sd0JwsuPLqb3SYmusag+edF6ziyMensPVqLTSc1PiSEA==
dependencies:
follow-redirects "^1.15.0"
form-data "^4.0.0"
proxy-from-env "^1.1.0"
axobject-query@^2.2.0: axobject-query@^2.2.0:
version "2.2.0" version "2.2.0"
resolved "https://registry.npmjs.org/axobject-query/-/axobject-query-2.2.0.tgz" resolved "https://registry.npmjs.org/axobject-query/-/axobject-query-2.2.0.tgz"
@ -3797,7 +3814,7 @@ colorette@^2.0.10:
combined-stream@^1.0.8: combined-stream@^1.0.8:
version "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== integrity sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==
dependencies: dependencies:
delayed-stream "~1.0.0" delayed-stream "~1.0.0"
@ -4278,7 +4295,7 @@ defined@^1.0.0:
delayed-stream@~1.0.0: delayed-stream@~1.0.0:
version "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== integrity sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==
depd@2.0.0: depd@2.0.0:
@ -5180,7 +5197,7 @@ flatted@^3.1.0:
follow-redirects@^1.0.0, follow-redirects@^1.15.0: follow-redirects@^1.0.0, follow-redirects@^1.15.0:
version "1.15.2" 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== integrity sha512-VQLG33o04KaQ8uYi2tVNbdrWp1QWxNNea+nmIB4EVM28v0hmP17z7aG1+wAkNzVq4KeXTq3221ye5qTJP91JwA==
for-each@^0.3.3: for-each@^0.3.3:
@ -5220,7 +5237,7 @@ form-data@^3.0.0:
form-data@^4.0.0: form-data@^4.0.0:
version "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== integrity sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==
dependencies: dependencies:
asynckit "^0.4.0" asynckit "^0.4.0"
@ -7341,12 +7358,12 @@ micromatch@^4.0.2, micromatch@^4.0.4, micromatch@^4.0.5:
mime-db@1.52.0, "mime-db@>= 1.43.0 < 2": mime-db@1.52.0, "mime-db@>= 1.43.0 < 2":
version "1.52.0" 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== 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: 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" 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== integrity sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==
dependencies: dependencies:
mime-db "1.52.0" mime-db "1.52.0"
@ -8504,7 +8521,7 @@ proxy-addr@~2.0.7:
proxy-from-env@^1.1.0: proxy-from-env@^1.1.0:
version "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== integrity sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==
psl@^1.1.33: psl@^1.1.33: