refactor: server requests

This commit is contained in:
IlyaDoronin 2023-08-31 13:02:11 +03:00 committed by Nastya
parent 8d150fe34b
commit 801df79eba
15 changed files with 1232 additions and 999 deletions

@ -2,13 +2,66 @@ import { makeRequest } from "@frontend/kitui";
import { parseAxiosError } from "@root/utils/parse-error";
import type {
LoginRequest,
LoginResponse,
RegisterRequest,
RegisterResponse,
} from "@frontend/kitui";
const apiUrl =
process.env.NODE_ENV === "production" ? "" : "https://hub.pena.digital";
process.env.NODE_ENV === "production"
? "/auth"
: "https://hub.pena.digital/auth";
export async function register(
login: string,
password: string,
phoneNumber: string
): Promise<[RegisterResponse | null, string?]> {
try {
const registerResponse = await makeRequest<
RegisterRequest,
RegisterResponse
>({
url: apiUrl + "/register",
body: { login, password, phoneNumber },
useToken: false,
withCredentials: true,
});
return [registerResponse];
} catch (nativeError) {
const [error] = parseAxiosError(nativeError);
return [null, `Не удалось зарегестрировать аккаунт. ${error}`];
}
}
export async function login(
login: string,
password: string
): Promise<[LoginResponse | null, string?]> {
try {
const loginResponse = await makeRequest<LoginRequest, LoginResponse>({
url: apiUrl + "/login",
body: { login, password },
useToken: false,
withCredentials: true,
});
return [loginResponse];
} catch (nativeError) {
const [error] = parseAxiosError(nativeError);
return [null, `Не удалось войти. ${error}`];
}
}
export async function logout(): Promise<[unknown, string?]> {
try {
const logoutResponse = await makeRequest<never, void>({
url: apiUrl + "/auth/logout",
url: apiUrl + "/logout",
method: "POST",
useToken: true,
withCredentials: true,

29
src/api/price.ts Normal file

@ -0,0 +1,29 @@
import { makeRequest } from "@frontend/kitui";
import { parseAxiosError } from "@root/utils/parse-error";
import type { GetDiscountsResponse } from "@root/model/discount";
const apiUrl =
process.env.NODE_ENV === "production"
? "/price"
: "https://hub.pena.digital/price";
export async function getDiscounts(
signal: AbortSignal | undefined
): Promise<[GetDiscountsResponse | null, string?]> {
try {
const discountsResponse = await makeRequest<never, GetDiscountsResponse>({
url: apiUrl + "/discounts",
method: "get",
useToken: true,
signal,
});
return [discountsResponse];
} catch (nativeError) {
const [error] = parseAxiosError(nativeError);
return [null, `Ошибка получения списка скидок. ${error}`];
}
}

@ -2,11 +2,35 @@ import { Tariff, makeRequest } from "@frontend/kitui";
import { CreateTariffBody } from "@root/model/customTariffs";
import { parseAxiosError } from "@root/utils/parse-error";
import type { ServiceKeyToPrivilegesMap } from "@root/model/privilege";
import type { GetTariffsResponse } from "@root/model/tariff";
const apiUrl =
process.env.NODE_ENV === "production"
? "/strator"
: "https://hub.pena.digital/strator";
export async function getTariffs(
apiPage: number,
tariffsPerPage: number,
signal: AbortSignal | undefined
): Promise<[GetTariffsResponse | null, string?]> {
try {
const tariffsResponse = await makeRequest<never, GetTariffsResponse>({
url: apiUrl + `/tariff?page=${apiPage}&limit=${tariffsPerPage}`,
method: "get",
useToken: true,
signal,
});
return [tariffsResponse];
} catch (nativeError) {
const [error] = parseAxiosError(nativeError);
return [null, `Не удалось получить список тарифов. ${error}`];
}
}
export async function createTariff(
tariff: CreateTariffBody
): Promise<[Tariff | null, string?]> {
@ -43,3 +67,25 @@ export async function getTariffById(
return [null, `Не удалось получить тарифы. ${error}`, status];
}
}
export async function getCustomTariffs(
signal: AbortSignal | undefined
): Promise<[ServiceKeyToPrivilegesMap | null, string?]> {
try {
const getCustomTariffsResponse = await makeRequest<
null,
ServiceKeyToPrivilegesMap
>({
url: apiUrl + "/privilege/service",
signal,
method: "get",
useToken: true,
});
return [getCustomTariffsResponse];
} catch (nativeError) {
const [error] = parseAxiosError(nativeError);
return [null, `Не удалось получить кастомные тарифы. ${error}`];
}
}

50
src/api/ticket.ts Normal file

@ -0,0 +1,50 @@
import { makeRequest } from "@frontend/kitui";
import { parseAxiosError } from "@root/utils/parse-error";
import { SendTicketMessageRequest } from "@frontend/kitui";
const apiUrl =
process.env.NODE_ENV === "production"
? "/heruvym"
: "https://hub.pena.digital/heruvym";
export async function sendTicketMessage(
ticketId: string,
message: string
): Promise<[null, string?]> {
try {
const sendTicketMessageResponse = await makeRequest<
SendTicketMessageRequest,
null
>({
url: `${apiUrl}/send`,
method: "POST",
useToken: false,
body: { ticket: ticketId, message: message, lang: "ru", files: [] },
withCredentials: true,
});
return [sendTicketMessageResponse];
} catch (nativeError) {
const [error] = parseAxiosError(nativeError);
return [null, `Не удалось отправить сообщение. ${error}`];
}
}
export async function shownMessage(id: string): Promise<[null, string?]> {
try {
const shownMessageResponse = await makeRequest<{ id: string }, null>({
url: apiUrl + "/shown",
method: "POST",
useToken: true,
body: { id },
});
return [shownMessageResponse];
} catch (nativeError) {
const [error] = parseAxiosError(nativeError);
return [null, `Не удалось прочесть сообщение. ${error}`];
}
}

@ -3,14 +3,16 @@ import { PatchUserRequest } from "@root/model/user";
import { parseAxiosError } from "@root/utils/parse-error";
const apiUrl =
process.env.NODE_ENV === "production" ? "" : "https://hub.pena.digital";
process.env.NODE_ENV === "production"
? "/user"
: "https://hub.pena.digital/user";
export async function patchUser(
user: PatchUserRequest
): Promise<[User | null, string?]> {
try {
const patchUserResponse = await makeRequest<PatchUserRequest, User>({
url: apiUrl + "/user/",
url: apiUrl,
contentType: true,
method: "PATCH",
useToken: true,

@ -6,14 +6,16 @@ import { parseAxiosError } from "@root/utils/parse-error";
import type { Verification, SendDocumentsArgs } from "@root/model/auth";
const apiUrl =
process.env.NODE_ENV === "production" ? "" : "https://hub.pena.digital";
process.env.NODE_ENV === "production"
? "/verification"
: "https://hub.pena.digital/verification";
export async function verification(
userId: string
): Promise<[Verification | null, string?]> {
try {
const verificationResponse = await makeRequest<never, Verification>({
url: apiUrl + "/verification/verification/" + userId,
url: apiUrl + "/verification" + userId,
method: "GET",
useToken: true,
withCredentials: true,
@ -32,7 +34,7 @@ export async function sendDocuments(
): Promise<[Verification | null, string?]> {
try {
const sendDocumentsResponse = await makeRequest<FormData, Verification>({
url: apiUrl + "/verification/verification",
url: apiUrl + "/verification",
method: "POST",
useToken: true,
withCredentials: true,

@ -1,265 +1,319 @@
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 { TicketMessage } from "@frontend/kitui";
import { addOrUpdateUnauthMessages, useUnauthTicketStore, incrementUnauthMessageApiPage, setUnauthIsPreventAutoscroll, setUnauthSessionData, setIsMessageSending, setUnauthTicketMessageFetchState } from "@root/stores/unauthTicket";
import {
addOrUpdateUnauthMessages,
useUnauthTicketStore,
incrementUnauthMessageApiPage,
setUnauthIsPreventAutoscroll,
setUnauthSessionData,
setIsMessageSending,
setUnauthTicketMessageFetchState,
} from "@root/stores/unauthTicket";
import { enqueueSnackbar } from "notistack";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import ChatMessage from "../ChatMessage";
import SendIcon from "../icons/SendIcon";
import UserCircleIcon from "./UserCircleIcon";
import { throttle } from "@frontend/kitui";
import { makeRequest } from "@frontend/kitui";
import { useTicketMessages, getMessageFromFetchError, useSSESubscription, useEventListener, createTicket } from "@frontend/kitui";
import {
useTicketMessages,
getMessageFromFetchError,
useSSESubscription,
useEventListener,
createTicket,
} from "@frontend/kitui";
import { sendTicketMessage } from "@root/api/ticket";
interface Props {
sx?: SxProps<Theme>;
sx?: SxProps<Theme>;
}
export default function Chat({ sx }: Props) {
const theme = useTheme();
const upMd = useMediaQuery(theme.breakpoints.up("md"));
const [messageField, setMessageField] = useState<string>("");
const sessionData = useUnauthTicketStore(state => state.sessionData);
const messages = useUnauthTicketStore(state => state.messages);
const messageApiPage = useUnauthTicketStore(state => state.apiPage);
const messagesPerPage = useUnauthTicketStore(state => state.messagesPerPage);
const isMessageSending = useUnauthTicketStore(state => state.isMessageSending);
const isPreventAutoscroll = useUnauthTicketStore(state => state.isPreventAutoscroll);
const lastMessageId = useUnauthTicketStore(state => state.lastMessageId);
const fetchState = useUnauthTicketStore(state => state.unauthTicketMessageFetchState);
const chatBoxRef = useRef<HTMLDivElement>(null);
const theme = useTheme();
const upMd = useMediaQuery(theme.breakpoints.up("md"));
const [messageField, setMessageField] = useState<string>("");
const sessionData = useUnauthTicketStore((state) => state.sessionData);
const messages = useUnauthTicketStore((state) => state.messages);
const messageApiPage = useUnauthTicketStore((state) => state.apiPage);
const messagesPerPage = useUnauthTicketStore(
(state) => state.messagesPerPage
);
const isMessageSending = useUnauthTicketStore(
(state) => state.isMessageSending
);
const isPreventAutoscroll = useUnauthTicketStore(
(state) => state.isPreventAutoscroll
);
const lastMessageId = useUnauthTicketStore((state) => state.lastMessageId);
const fetchState = useUnauthTicketStore(
(state) => state.unauthTicketMessageFetchState
);
const chatBoxRef = useRef<HTMLDivElement>(null);
useTicketMessages({
url: "https://admin.pena.digital/heruvym/getMessages",
isUnauth: true,
ticketId: sessionData?.ticketId,
messagesPerPage,
messageApiPage,
onSuccess: 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);
}, []),
onFetchStateChange: setUnauthTicketMessageFetchState,
});
useTicketMessages({
url: "https://hub.pena.digital/heruvym/getMessages",
isUnauth: true,
ticketId: sessionData?.ticketId,
messagesPerPage,
messageApiPage,
onSuccess: 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);
}, []),
onFetchStateChange: setUnauthTicketMessageFetchState,
});
useSSESubscription<TicketMessage>({
enabled: Boolean(sessionData),
url: `https://hub.pena.digital/heruvym/ticket?ticket=${sessionData?.ticketId}&s=${sessionData?.sessionId}`,
onNewData: addOrUpdateUnauthMessages,
onDisconnect: useCallback(() => {
setUnauthIsPreventAutoscroll(false);
}, []),
marker: "ticket"
});
useSSESubscription<TicketMessage>({
enabled: Boolean(sessionData),
url: `https://hub.pena.digital/heruvym/ticket?ticket=${sessionData?.ticketId}&s=${sessionData?.sessionId}`,
onNewData: addOrUpdateUnauthMessages,
onDisconnect: useCallback(() => {
setUnauthIsPreventAutoscroll(false);
}, []),
marker: "ticket",
});
const throttledScrollHandler = useMemo(() => throttle(() => {
const throttledScrollHandler = useMemo(
() =>
throttle(() => {
const chatBox = chatBoxRef.current;
if (!chatBox) return;
const scrollBottom = chatBox.scrollHeight - chatBox.scrollTop - chatBox.clientHeight;
const scrollBottom =
chatBox.scrollHeight - chatBox.scrollTop - chatBox.clientHeight;
const isPreventAutoscroll = scrollBottom > chatBox.clientHeight;
setUnauthIsPreventAutoscroll(isPreventAutoscroll);
if (fetchState !== "idle") return;
if (chatBox.scrollTop < chatBox.clientHeight) {
incrementUnauthMessageApiPage();
incrementUnauthMessageApiPage();
}
}, 200), [fetchState]);
}, 200),
[fetchState]
);
useEventListener("scroll", throttledScrollHandler, chatBoxRef);
useEventListener("scroll", throttledScrollHandler, chatBoxRef);
useEffect(function scrollOnNewMessage() {
if (!chatBoxRef.current) return;
useEffect(
function scrollOnNewMessage() {
if (!chatBoxRef.current) return;
if (!isPreventAutoscroll) {
setTimeout(() => {
scrollToBottom();
}, 50);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [lastMessageId]);
if (!isPreventAutoscroll) {
setTimeout(() => {
scrollToBottom();
}, 50);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
},
[lastMessageId]
);
async function handleSendMessage() {
if (!messageField || isMessageSending) return;
async function handleSendMessage() {
if (!messageField || isMessageSending) return;
if (!sessionData) {
setIsMessageSending(true);
createTicket({
url: "https://hub.pena.digital/heruvym/create",
body: {
Title: "Unauth title",
Message: messageField,
},
useToken: false,
}).then(response => {
setUnauthSessionData({
ticketId: response.Ticket,
sessionId: response.sess,
});
}).catch(error => {
const errorMessage = getMessageFromFetchError(error);
if (errorMessage) enqueueSnackbar(errorMessage);
}).finally(() => {
setMessageField("");
setIsMessageSending(false);
});
} else {
setIsMessageSending(true);
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(() => {
setMessageField("");
setIsMessageSending(false);
});
}
}
function scrollToBottom(behavior?: ScrollBehavior) {
if (!chatBoxRef.current) return;
const chatBox = chatBoxRef.current;
chatBox.scroll({
left: 0,
top: chatBox.scrollHeight,
behavior,
if (!sessionData) {
setIsMessageSending(true);
createTicket({
url: "https://hub.pena.digital/heruvym/create",
body: {
Title: "Unauth title",
Message: messageField,
},
useToken: false,
})
.then((response) => {
setUnauthSessionData({
ticketId: response.Ticket,
sessionId: response.sess,
});
})
.catch((error) => {
const errorMessage = getMessageFromFetchError(error);
if (errorMessage) enqueueSnackbar(errorMessage);
})
.finally(() => {
setMessageField("");
setIsMessageSending(false);
});
} else {
setIsMessageSending(true);
const [_, sendTicketMessageError] = await sendTicketMessage(
sessionData.ticketId,
messageField
);
if (sendTicketMessageError) {
enqueueSnackbar(sendTicketMessageError);
}
setMessageField("");
setIsMessageSending(false);
}
}
const handleTextfieldKeyPress: React.KeyboardEventHandler<HTMLInputElement | HTMLTextAreaElement> = (e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSendMessage();
}
};
function scrollToBottom(behavior?: ScrollBehavior) {
if (!chatBoxRef.current) return;
return (
<Box sx={{
const chatBox = chatBoxRef.current;
chatBox.scroll({
left: 0,
top: chatBox.scrollHeight,
behavior,
});
}
const handleTextfieldKeyPress: React.KeyboardEventHandler<
HTMLInputElement | HTMLTextAreaElement
> = (e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSendMessage();
}
};
return (
<Box
sx={{
display: "flex",
flexDirection: "column",
height: "clamp(250px, calc(100vh - 90px), 600px)",
backgroundColor: "#944FEE",
borderRadius: "8px",
...sx,
}}
>
<Box
sx={{
display: "flex",
gap: "9px",
pl: "22px",
pt: "12px",
pb: "20px",
filter: "drop-shadow(0px 3px 12px rgba(37, 39, 52, 0.3))",
}}
>
<UserCircleIcon />
<Box
sx={{
mt: "5px",
display: "flex",
flexDirection: "column",
height: "clamp(250px, calc(100vh - 90px), 600px)",
backgroundColor: "#944FEE",
borderRadius: "8px",
...sx,
}}>
<Box sx={{
display: "flex",
gap: "9px",
pl: "22px",
pt: "12px",
pb: "20px",
filter: "drop-shadow(0px 3px 12px rgba(37, 39, 52, 0.3))",
}}>
<UserCircleIcon />
<Box sx={{
mt: "5px",
display: "flex",
flexDirection: "column",
gap: "3px",
}}>
<Typography>Мария</Typography>
<Typography sx={{
fontSize: "16px",
lineHeight: "19px",
}}>онлайн-консультант</Typography>
</Box>
</Box>
<Box sx={{
flexGrow: 1,
backgroundColor: "white",
borderRadius: "8px",
display: "flex",
flexDirection: "column",
}}>
<Box
ref={chatBoxRef}
sx={{
display: "flex",
width: "100%",
flexBasis: 0,
flexDirection: "column",
gap: upMd ? "20px" : "16px",
px: upMd ? "20px" : "5px",
py: upMd ? "20px" : "13px",
overflowY: "auto",
flexGrow: 1,
}}
>
{sessionData && messages.map((message) => (
<ChatMessage
unAuthenticated
key={message.id}
text={message.message}
createdAt={message.created_at}
isSelf={sessionData.sessionId === message.user_id}
/>
))}
</Box>
<FormControl fullWidth sx={{ borderTop: "1px solid black" }}>
<InputBase
value={messageField}
fullWidth
placeholder="Введите сообщение..."
id="message"
multiline
onKeyDown={handleTextfieldKeyPress}
sx={{
width: "100%",
p: 0,
}}
inputProps={{
sx: {
fontWeight: 400,
fontSize: "16px",
lineHeight: "19px",
pt: upMd ? "30px" : "28px",
pb: upMd ? "30px" : "24px",
px: "19px",
maxHeight: "calc(19px * 5)",
color: "black",
},
}}
onChange={(e) => setMessageField(e.target.value)}
endAdornment={
<InputAdornment position="end">
<IconButton
disabled={isMessageSending}
onClick={handleSendMessage}
sx={{
height: "53px",
width: "53px",
mr: "13px",
p: 0,
opacity: isMessageSending ? 0.3 : 1,
}}
>
<SendIcon style={{
width: "100%",
height: "100%",
}} />
</IconButton>
</InputAdornment>
}
/>
</FormControl>
</Box>
gap: "3px",
}}
>
<Typography>Мария</Typography>
<Typography
sx={{
fontSize: "16px",
lineHeight: "19px",
}}
>
онлайн-консультант
</Typography>
</Box>
);
</Box>
<Box
sx={{
flexGrow: 1,
backgroundColor: "white",
borderRadius: "8px",
display: "flex",
flexDirection: "column",
}}
>
<Box
ref={chatBoxRef}
sx={{
display: "flex",
width: "100%",
flexBasis: 0,
flexDirection: "column",
gap: upMd ? "20px" : "16px",
px: upMd ? "20px" : "5px",
py: upMd ? "20px" : "13px",
overflowY: "auto",
flexGrow: 1,
}}
>
{sessionData &&
messages.map((message) => (
<ChatMessage
unAuthenticated
key={message.id}
text={message.message}
createdAt={message.created_at}
isSelf={sessionData.sessionId === message.user_id}
/>
))}
</Box>
<FormControl fullWidth sx={{ borderTop: "1px solid black" }}>
<InputBase
value={messageField}
fullWidth
placeholder="Введите сообщение..."
id="message"
multiline
onKeyDown={handleTextfieldKeyPress}
sx={{
width: "100%",
p: 0,
}}
inputProps={{
sx: {
fontWeight: 400,
fontSize: "16px",
lineHeight: "19px",
pt: upMd ? "30px" : "28px",
pb: upMd ? "30px" : "24px",
px: "19px",
maxHeight: "calc(19px * 5)",
color: "black",
},
}}
onChange={(e) => setMessageField(e.target.value)}
endAdornment={
<InputAdornment position="end">
<IconButton
disabled={isMessageSending}
onClick={handleSendMessage}
sx={{
height: "53px",
width: "53px",
mr: "13px",
p: 0,
opacity: isMessageSending ? 0.3 : 1,
}}
>
<SendIcon
style={{
width: "100%",
height: "100%",
}}
/>
</IconButton>
</InputAdornment>
}
/>
</FormControl>
</Box>
</Box>
);
}

@ -17,7 +17,7 @@ export default function ProtectedLayout() {
const ticketsPerPage = useTicketStore((state) => state.ticketsPerPage);
useSSESubscription<Ticket>({
url: `https://admin.pena.digital/heruvym/subscribe?Authorization=${token}`,
url: `https://hub.pena.digital/heruvym/subscribe?Authorization=${token}`,
onNewData: data => {
updateTickets(data.filter(d => Boolean(d.id)));
setTicketCount(data.length);
@ -49,14 +49,9 @@ export default function ProtectedLayout() {
});
useCustomTariffs({
url: "https://admin.pena.digital/strator/privilege/service",
onNewUser: setCustomTariffs,
onError: (error) => {
const errorMessage = getMessageFromFetchError(
error,
"Не удалось получить кастомные тарифы"
);
if (errorMessage) enqueueSnackbar(errorMessage);
if (error) enqueueSnackbar(error);
},
});

@ -1,6 +1,6 @@
import type { Attachment } from "@root/model/attachment";
type File = {
export type File = {
name: "inn" | "rule" | "egrule" | "certificate";
url: string;
};

@ -1,329 +1,320 @@
import {
Box,
Button,
Fab,
FormControl,
IconButton,
InputAdornment,
InputBase,
Typography,
useMediaQuery,
useTheme,
Box,
Button,
Fab,
FormControl,
IconButton,
InputAdornment,
InputBase,
Typography,
useMediaQuery,
useTheme,
} from "@mui/material";
import ArrowDownwardIcon from "@mui/icons-material/ArrowDownward";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useParams } from "react-router-dom";
import SendIcon from "@components/icons/SendIcon";
import { makeRequest, throttle, useToken } from "@frontend/kitui";
import { throttle, useToken } from "@frontend/kitui";
import { enqueueSnackbar } from "notistack";
import { useTicketStore } from "@root/stores/tickets";
import {
addOrUpdateMessages,
clearMessageState,
incrementMessageApiPage,
setIsPreventAutoscroll,
setTicketMessageFetchState,
useMessageStore,
addOrUpdateMessages,
clearMessageState,
incrementMessageApiPage,
setIsPreventAutoscroll,
setTicketMessageFetchState,
useMessageStore,
} from "@root/stores/messages";
import { TicketMessage } from "@frontend/kitui";
import ChatMessage from "@root/components/ChatMessage";
import { cardShadow } from "@root/utils/theme";
import {
getMessageFromFetchError,
useEventListener,
useSSESubscription,
useTicketMessages,
getMessageFromFetchError,
useEventListener,
useSSESubscription,
useTicketMessages,
} from "@frontend/kitui";
import { shownMessage, sendTicketMessage } from "@root/api/ticket";
export default function SupportChat() {
const theme = useTheme();
const upMd = useMediaQuery(theme.breakpoints.up("md"));
const isMobile = useMediaQuery(theme.breakpoints.up(460));
const [messageField, setMessageField] = useState<string>("");
const tickets = useTicketStore((state) => state.tickets);
const messages = useMessageStore((state) => state.messages);
const messageApiPage = useMessageStore((state) => state.apiPage);
const lastMessageId = useMessageStore((state) => state.lastMessageId);
const messagesPerPage = useMessageStore((state) => state.messagesPerPage);
const isPreventAutoscroll = useMessageStore(
(state) => state.isPreventAutoscroll
);
const token = useToken();
const ticketId = useParams().ticketId;
const ticket = tickets.find((ticket) => ticket.id === ticketId);
const chatBoxRef = useRef<HTMLDivElement>(null);
const fetchState = useMessageStore(state => state.ticketMessageFetchState);
const theme = useTheme();
const upMd = useMediaQuery(theme.breakpoints.up("md"));
const isMobile = useMediaQuery(theme.breakpoints.up(460));
const [messageField, setMessageField] = useState<string>("");
const tickets = useTicketStore((state) => state.tickets);
const messages = useMessageStore((state) => state.messages);
const messageApiPage = useMessageStore((state) => state.apiPage);
const lastMessageId = useMessageStore((state) => state.lastMessageId);
const messagesPerPage = useMessageStore((state) => state.messagesPerPage);
const isPreventAutoscroll = useMessageStore(
(state) => state.isPreventAutoscroll
);
const token = useToken();
const ticketId = useParams().ticketId;
const ticket = tickets.find((ticket) => ticket.id === ticketId);
const chatBoxRef = useRef<HTMLDivElement>(null);
const fetchState = useMessageStore((state) => state.ticketMessageFetchState);
useTicketMessages({
url: "https://admin.pena.digital/heruvym/getMessages",
ticketId,
messagesPerPage,
messageApiPage,
onSuccess: (messages) => {
if (chatBoxRef.current && chatBoxRef.current.scrollTop < 1)
chatBoxRef.current.scrollTop = 1;
addOrUpdateMessages(messages);
},
onError: (error: Error) => {
const message = getMessageFromFetchError(error);
if (message) enqueueSnackbar(message);
},
onFetchStateChange: setTicketMessageFetchState,
});
useTicketMessages({
url: "https://hub.pena.digital/heruvym/getMessages",
ticketId,
messagesPerPage,
messageApiPage,
onSuccess: (messages) => {
if (chatBoxRef.current && chatBoxRef.current.scrollTop < 1)
chatBoxRef.current.scrollTop = 1;
addOrUpdateMessages(messages);
},
onError: (error: Error) => {
const message = getMessageFromFetchError(error);
if (message) enqueueSnackbar(message);
},
onFetchStateChange: setTicketMessageFetchState,
});
useSSESubscription<TicketMessage>({
enabled: Boolean(token) && Boolean(ticketId),
url: `https://admin.pena.digital/heruvym/ticket?ticket=${ticketId}&Authorization=${token}`,
onNewData: addOrUpdateMessages,
onDisconnect: useCallback(() => {
clearMessageState();
setIsPreventAutoscroll(false);
}, []),
marker: "ticket message",
});
const throttledScrollHandler = useMemo(
() =>
throttle(() => {
const chatBox = chatBoxRef.current;
if (!chatBox) return;
const scrollBottom =
chatBox.scrollHeight - chatBox.scrollTop - chatBox.clientHeight;
const isPreventAutoscroll = scrollBottom > chatBox.clientHeight * 20;
setIsPreventAutoscroll(isPreventAutoscroll);
if (fetchState !== "idle") return;
if (chatBox.scrollTop < chatBox.clientHeight) {
incrementMessageApiPage();
}
}, 200),
[fetchState]
);
useEventListener("scroll", throttledScrollHandler, chatBoxRef);
useEffect(
function scrollOnNewMessage() {
if (!chatBoxRef.current) return;
if (!isPreventAutoscroll) {
setTimeout(() => {
scrollToBottom();
}, 50);
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[lastMessageId]
);
useEffect(() => {
if (ticket)
makeRequest({
url: "https://admin.pena.digital/heruvym/shown",
method: "POST",
useToken: true,
body: { id: ticket.top_message.id },
});
}, [ticket]);
function handleSendMessage() {
if (!ticket || !messageField) return;
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);
});
}
function scrollToBottom(behavior?: ScrollBehavior) {
if (!chatBoxRef.current) return;
useSSESubscription<TicketMessage>({
enabled: Boolean(token) && Boolean(ticketId),
url: `https://hub.pena.digital/heruvym/ticket?ticket=${ticketId}&Authorization=${token}`,
onNewData: addOrUpdateMessages,
onDisconnect: useCallback(() => {
clearMessageState();
setIsPreventAutoscroll(false);
}, []),
marker: "ticket message",
});
const throttledScrollHandler = useMemo(
() =>
throttle(() => {
const chatBox = chatBoxRef.current;
chatBox.scroll({
left: 0,
top: chatBox.scrollHeight,
behavior,
});
if (!chatBox) return;
const scrollBottom =
chatBox.scrollHeight - chatBox.scrollTop - chatBox.clientHeight;
const isPreventAutoscroll = scrollBottom > chatBox.clientHeight * 20;
setIsPreventAutoscroll(isPreventAutoscroll);
if (fetchState !== "idle") return;
if (chatBox.scrollTop < chatBox.clientHeight) {
incrementMessageApiPage();
}
}, 200),
[fetchState]
);
useEventListener("scroll", throttledScrollHandler, chatBoxRef);
useEffect(
function scrollOnNewMessage() {
if (!chatBoxRef.current) return;
if (!isPreventAutoscroll) {
setTimeout(() => {
scrollToBottom();
}, 50);
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[lastMessageId]
);
useEffect(() => {
if (ticket) {
shownMessage(ticket.top_message.id);
}
}, [ticket]);
async function handleSendMessage() {
if (!ticket || !messageField) return;
const [_, sendTicketMessageError] = await sendTicketMessage(
ticket.id,
messageField
);
if (sendTicketMessageError) {
return enqueueSnackbar(sendTicketMessageError);
}
const createdAt = ticket && new Date(ticket.created_at);
const createdAtString =
createdAt &&
createdAt.toLocaleDateString(undefined, {
year: "numeric",
month: "2-digit",
day: "2-digit",
}) +
" " +
createdAt.toLocaleTimeString(undefined, {
hour: "2-digit",
minute: "2-digit",
});
setMessageField("");
}
return (
<Box
sx={{
backgroundColor: upMd ? "white" : undefined,
display: "flex",
flexGrow: 1,
maxHeight: upMd ? "443px" : undefined,
borderRadius: "12px",
p: upMd ? "20px" : undefined,
gap: "40px",
height: !upMd ? `calc(100% - ${isMobile ? 90 : 115}px)` : null,
boxShadow: upMd ? cardShadow : undefined,
}}
function scrollToBottom(behavior?: ScrollBehavior) {
if (!chatBoxRef.current) return;
const chatBox = chatBoxRef.current;
chatBox.scroll({
left: 0,
top: chatBox.scrollHeight,
behavior,
});
}
const createdAt = ticket && new Date(ticket.created_at);
const createdAtString =
createdAt &&
createdAt.toLocaleDateString(undefined, {
year: "numeric",
month: "2-digit",
day: "2-digit",
}) +
" " +
createdAt.toLocaleTimeString(undefined, {
hour: "2-digit",
minute: "2-digit",
});
return (
<Box
sx={{
backgroundColor: upMd ? "white" : undefined,
display: "flex",
flexGrow: 1,
maxHeight: upMd ? "443px" : undefined,
borderRadius: "12px",
p: upMd ? "20px" : undefined,
gap: "40px",
height: !upMd ? `calc(100% - ${isMobile ? 90 : 115}px)` : null,
boxShadow: upMd ? cardShadow : undefined,
}}
>
<Box
sx={{
display: "flex",
alignItems: "start",
flexDirection: "column",
flexGrow: 1,
}}
>
<Typography variant={upMd ? "h5" : "body2"} mb={"4px"}>
{ticket?.title}
</Typography>
<Typography
sx={{
fontWeight: 400,
fontSize: "14px",
lineHeight: "17px",
color: theme.palette.gray.main,
mb: upMd ? "9px" : "20px",
}}
>
<Box
Создан: {createdAtString}
</Typography>
<Box
sx={{
backgroundColor: "#ECECF3",
border: `1px solid ${theme.palette.gray.main}`,
borderRadius: "10px",
overflow: "hidden",
width: "100%",
minHeight: "345px",
display: "flex",
flexGrow: 1,
flexDirection: "column",
}}
>
<Box
sx={{
position: "relative",
width: "100%",
flexGrow: 1,
borderBottom: `1px solid ${theme.palette.gray.main}`,
height: "200px",
}}
>
{isPreventAutoscroll && (
<Fab
size="small"
onClick={() => scrollToBottom("smooth")}
sx={{
display: "flex",
alignItems: "start",
flexDirection: "column",
flexGrow: 1,
position: "absolute",
left: "10px",
bottom: "10px",
}}
>
<Typography variant={upMd ? "h5" : "body2"} mb={"4px"}>
{ticket?.title}
</Typography>
<Typography
sx={{
fontWeight: 400,
fontSize: "14px",
lineHeight: "17px",
color: theme.palette.gray.main,
mb: upMd ? "9px" : "20px",
}}
>
Создан: {createdAtString}
</Typography>
<Box
sx={{
backgroundColor: "#ECECF3",
border: `1px solid ${theme.palette.gray.main}`,
borderRadius: "10px",
overflow: "hidden",
width: "100%",
minHeight: "345px",
display: "flex",
flexGrow: 1,
flexDirection: "column",
}}
>
<Box
sx={{
position: "relative",
width: "100%",
flexGrow: 1,
borderBottom: `1px solid ${theme.palette.gray.main}`,
height: "200px",
}}
>
{isPreventAutoscroll && (
<Fab
size="small"
onClick={() => scrollToBottom("smooth")}
sx={{
position: "absolute",
left: "10px",
bottom: "10px",
}}
>
<ArrowDownwardIcon />
</Fab>
)}
<Box
ref={chatBoxRef}
sx={{
display: "flex",
width: "100%",
flexDirection: "column",
gap: upMd ? "20px" : "16px",
px: upMd ? "20px" : "5px",
py: upMd ? "20px" : "13px",
overflowY: "auto",
height: "100%",
}}
>
{ticket &&
messages.map((message) => (
<ChatMessage
key={message.id}
text={message.message}
createdAt={message.created_at}
isSelf={ticket.user === message.user_id}
/>
))}
</Box>
</Box>
<FormControl>
<InputBase
value={messageField}
fullWidth
placeholder="Текст обращения"
id="message"
multiline
sx={{
width: "100%",
p: 0,
}}
inputProps={{
sx: {
fontWeight: 400,
fontSize: "16px",
lineHeight: "19px",
pt: upMd ? "13px" : "28px",
pb: upMd ? "13px" : "24px",
px: "19px",
maxHeight: "calc(19px * 5)",
},
}}
onChange={(e) => setMessageField(e.target.value)}
endAdornment={
!upMd && (
<InputAdornment position="end">
<IconButton
onClick={handleSendMessage}
sx={{
height: "45px",
width: "45px",
mr: "13px",
p: 0,
}}
>
<SendIcon />
</IconButton>
</InputAdornment>
)
}
/>
</FormControl>
</Box>
</Box>
{upMd && (
<Box sx={{ alignSelf: "end" }}>
<Button
variant="pena-contained-dark"
onClick={handleSendMessage}
disabled={!messageField}
>Отправить</Button>
</Box>
>
<ArrowDownwardIcon />
</Fab>
)}
<Box
ref={chatBoxRef}
sx={{
display: "flex",
width: "100%",
flexDirection: "column",
gap: upMd ? "20px" : "16px",
px: upMd ? "20px" : "5px",
py: upMd ? "20px" : "13px",
overflowY: "auto",
height: "100%",
}}
>
{ticket &&
messages.map((message) => (
<ChatMessage
key={message.id}
text={message.message}
createdAt={message.created_at}
isSelf={ticket.user === message.user_id}
/>
))}
</Box>
</Box>
<FormControl>
<InputBase
value={messageField}
fullWidth
placeholder="Текст обращения"
id="message"
multiline
sx={{
width: "100%",
p: 0,
}}
inputProps={{
sx: {
fontWeight: 400,
fontSize: "16px",
lineHeight: "19px",
pt: upMd ? "13px" : "28px",
pb: upMd ? "13px" : "24px",
px: "19px",
maxHeight: "calc(19px * 5)",
},
}}
onChange={(e) => setMessageField(e.target.value)}
endAdornment={
!upMd && (
<InputAdornment position="end">
<IconButton
onClick={handleSendMessage}
sx={{
height: "45px",
width: "45px",
mr: "13px",
p: 0,
}}
>
<SendIcon />
</IconButton>
</InputAdornment>
)
}
/>
</FormControl>
</Box>
);
</Box>
{upMd && (
<Box sx={{ alignSelf: "end" }}>
<Button
variant="pena-contained-dark"
onClick={handleSendMessage}
disabled={!messageField}
>
Отправить
</Button>
</Box>
)}
</Box>
);
}

@ -6,7 +6,7 @@ import {
Typography,
useMediaQuery,
useTheme,
Button
Button,
} from "@mui/material";
import CloseIcon from "@mui/icons-material/Close";
import { useLocation, useNavigate } from "react-router-dom";
@ -18,20 +18,19 @@ import { Link as RouterLink } from "react-router-dom";
import { object, string } from "yup";
import { useEffect, useState } from "react";
import { setUserId, useUserStore } from "@root/stores/user";
import { LoginRequest, LoginResponse, getMessageFromFetchError } from "@frontend/kitui";
import { makeRequest } from "@frontend/kitui";
import { cardShadow } from "@root/utils/theme";
import PasswordInput from "@root/components/passwordInput";
import AmoButton from "./AmoButton";
import { login } from "@root/api/auth";
interface Values {
email: string;
password: string;
email: string;
password: string;
}
const initialValues: Values = {
email: "",
password: "",
email: "",
password: "",
};
const validationSchema = object({
@ -42,153 +41,150 @@ const validationSchema = object({
});
export default function SigninDialog() {
const [isDialogOpen, setIsDialogOpen] = useState<boolean>(true);
const user = useUserStore((state) => state.user);
const theme = useTheme();
const upMd = useMediaQuery(theme.breakpoints.up("md"));
const navigate = useNavigate();
const location = useLocation();
const formik = useFormik<Values>({
initialValues,
validationSchema,
onSubmit: (values, formikHelpers) => {
makeRequest<LoginRequest, LoginResponse>({
url: "https://hub.pena.digital/auth/login",
body: {
login: values.email.trim(),
password: values.password.trim(),
},
useToken: false,
withCredentials: true,
})
.then((result) => {
setUserId(result._id);
})
.catch((error: Error) => {
const errorMessage = getMessageFromFetchError(error);
if (errorMessage) enqueueSnackbar(errorMessage);
})
.finally(() => {
formikHelpers.setSubmitting(false);
});
const [isDialogOpen, setIsDialogOpen] = useState<boolean>(true);
const user = useUserStore((state) => state.user);
const theme = useTheme();
const upMd = useMediaQuery(theme.breakpoints.up("md"));
const navigate = useNavigate();
const location = useLocation();
const formik = useFormik<Values>({
initialValues,
validationSchema,
onSubmit: async (values, formikHelpers) => {
const [loginResponse, loginError] = await login(
values.email.trim(),
values.password.trim()
);
formikHelpers.setSubmitting(false);
if (loginError) {
return enqueueSnackbar(loginError);
}
if (loginResponse) {
setUserId(loginResponse._id);
}
},
});
useEffect(
function redirectIfSignedIn() {
if (user) navigate("/tariffs", { replace: true });
},
[navigate, user]
);
function handleClose() {
setIsDialogOpen(false);
setTimeout(() => navigate("/"), theme.transitions.duration.leavingScreen);
}
return (
<Dialog
open={isDialogOpen}
onClose={handleClose}
PaperProps={{
sx: {
width: "600px",
maxWidth: "600px",
},
});
useEffect(
function redirectIfSignedIn() {
if (user) navigate("/tariffs", { replace: true });
}}
slotProps={{
backdrop: {
style: {
backgroundColor: "rgb(0 0 0 / 0.7)",
},
},
[navigate, user]
);
function handleClose() {
setIsDialogOpen(false);
setTimeout(() => navigate("/"), theme.transitions.duration.leavingScreen);
}
return (
<Dialog
open={isDialogOpen}
onClose={handleClose}
PaperProps={{
sx: {
width: "600px",
maxWidth: "600px",
},
}}
slotProps={{
backdrop: {
style: {
backgroundColor: "rgb(0 0 0 / 0.7)",
},
},
}}
}}
>
<Box
component="form"
onSubmit={formik.handleSubmit}
noValidate
sx={{
position: "relative",
backgroundColor: "white",
display: "flex",
alignItems: "center",
flexDirection: "column",
p: upMd ? "50px" : "18px",
pb: upMd ? "40px" : "30px",
gap: "15px",
borderRadius: "12px",
boxShadow: cardShadow,
}}
>
<IconButton
onClick={handleClose}
sx={{
position: "absolute",
right: "7px",
top: "7px",
}}
>
<Box
component="form"
onSubmit={formik.handleSubmit}
noValidate
sx={{
position: "relative",
backgroundColor: "white",
display: "flex",
alignItems: "center",
flexDirection: "column",
p: upMd ? "50px" : "18px",
pb: upMd ? "40px" : "30px",
gap: "15px",
borderRadius: "12px",
boxShadow: cardShadow,
}}
>
<IconButton
onClick={handleClose}
sx={{
position: "absolute",
right: "7px",
top: "7px",
}}
>
<CloseIcon sx={{ transform: "scale(1.5)" }} />
</IconButton>
<Box>
<PenaLogo width={upMd ? 233 : 196} color="black" />
</Box>
<Typography
sx={{
color: theme.palette.gray.dark,
mt: "5px",
mb: upMd ? "10px" : "33px",
}}
>
Вход в личный кабинет
</Typography>
<InputTextfield
TextfieldProps={{
value: formik.values.email,
placeholder: "username",
onBlur: formik.handleBlur,
error: formik.touched.email && Boolean(formik.errors.email),
helperText: formik.touched.email && formik.errors.email,
}}
onChange={formik.handleChange}
color="#F2F3F7"
id="email"
label="Email"
gap={upMd ? "10px" : "10px"}
/>
<PasswordInput
TextfieldProps={{
value: formik.values.password,
placeholder: "Не менее 8 символов",
onBlur: formik.handleBlur,
error: formik.touched.password && Boolean(formik.errors.password),
helperText: formik.touched.password && formik.errors.password,
type: "password",
}}
onChange={formik.handleChange}
color="#F2F3F7"
id="password"
label="Пароль"
gap={upMd ? "10px" : "10px"}
/>
<Button
variant="pena-contained-dark"
fullWidth
type="submit"
disabled={formik.isSubmitting}
sx={{
py: "12px",
"&:hover": {
backgroundColor: theme.palette.purple.dark,
},
"&:active": {
color: "white",
backgroundColor: "black",
},
}}
>Войти</Button>
{/* <Link
<CloseIcon sx={{ transform: "scale(1.5)" }} />
</IconButton>
<Box>
<PenaLogo width={upMd ? 233 : 196} color="black" />
</Box>
<Typography
sx={{
color: theme.palette.gray.dark,
mt: "5px",
mb: upMd ? "10px" : "33px",
}}
>
Вход в личный кабинет
</Typography>
<InputTextfield
TextfieldProps={{
value: formik.values.email,
placeholder: "username",
onBlur: formik.handleBlur,
error: formik.touched.email && Boolean(formik.errors.email),
helperText: formik.touched.email && formik.errors.email,
}}
onChange={formik.handleChange}
color="#F2F3F7"
id="email"
label="Email"
gap={upMd ? "10px" : "10px"}
/>
<PasswordInput
TextfieldProps={{
value: formik.values.password,
placeholder: "Не менее 8 символов",
onBlur: formik.handleBlur,
error: formik.touched.password && Boolean(formik.errors.password),
helperText: formik.touched.password && formik.errors.password,
type: "password",
}}
onChange={formik.handleChange}
color="#F2F3F7"
id="password"
label="Пароль"
gap={upMd ? "10px" : "10px"}
/>
<Button
variant="pena-contained-dark"
fullWidth
type="submit"
disabled={formik.isSubmitting}
sx={{
py: "12px",
"&:hover": {
backgroundColor: theme.palette.purple.dark,
},
"&:active": {
color: "white",
backgroundColor: "black",
},
}}
>
Войти
</Button>
{/* <Link
component={RouterLink}
to="/"
href="#"
@ -199,29 +195,31 @@ export default function SigninDialog() {
>
Забыли пароль?
</Link> */}
<Box
sx={{
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: "10px",
mt: "auto",
}}
>
<Typography sx={{ color: theme.palette.purple.main, textAlign: "center" }}>
Вы еще не присоединились?
</Typography>
<Link
component={RouterLink}
to="/signup"
state={{ backgroundLocation: location.state.backgroundLocation }}
sx={{ color: theme.palette.purple.main }}
>
Регистрация
</Link>
</Box>
<AmoButton/>
</Box>
</Dialog>
);
<Box
sx={{
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: "10px",
mt: "auto",
}}
>
<Typography
sx={{ color: theme.palette.purple.main, textAlign: "center" }}
>
Вы еще не присоединились?
</Typography>
<Link
component={RouterLink}
to="/signup"
state={{ backgroundLocation: location.state.backgroundLocation }}
sx={{ color: theme.palette.purple.main }}
>
Регистрация
</Link>
</Box>
<AmoButton />
</Box>
</Dialog>
);
}

@ -1,4 +1,13 @@
import { Box, Button, Dialog, IconButton, Link, Typography, useMediaQuery, useTheme } from "@mui/material";
import {
Box,
Button,
Dialog,
IconButton,
Link,
Typography,
useMediaQuery,
useTheme,
} from "@mui/material";
import { useLocation, useNavigate } from "react-router-dom";
import { useFormik } from "formik";
import CloseIcon from "@mui/icons-material/Close";
@ -9,210 +18,211 @@ import { Link as RouterLink } from "react-router-dom";
import { object, ref, string } from "yup";
import { useEffect, useState } from "react";
import { setUserId, useUserStore } from "@root/stores/user";
import { RegisterRequest, RegisterResponse, getMessageFromFetchError } from "@frontend/kitui";
import { makeRequest } from "@frontend/kitui";
import { cardShadow } from "@root/utils/theme";
import PasswordInput from "@root/components/passwordInput";
import AmoButton from "./AmoButton";
import { register } from "@root/api/auth";
interface Values {
email: string;
password: string;
repeatPassword: string;
email: string;
password: string;
repeatPassword: string;
}
const initialValues: Values = {
email: "",
password: "",
repeatPassword: "",
email: "",
password: "",
repeatPassword: "",
};
const validationSchema = object({
email: string().required("Поле обязательно").email("Введите корректный email"),
password: string()
.min(8, "Минимум 8 символов")
.matches(/^[.,:;-_+\d\w]+$/, "Некорректные символы")
.required("Поле обязательно"),
repeatPassword: string()
.oneOf([ref("password"), undefined], "Пароли не совпадают")
.required("Повторите пароль"),
email: string()
.required("Поле обязательно")
.email("Введите корректный email"),
password: string()
.min(8, "Минимум 8 символов")
.matches(/^[.,:;-_+\d\w]+$/, "Некорректные символы")
.required("Поле обязательно"),
repeatPassword: string()
.oneOf([ref("password"), undefined], "Пароли не совпадают")
.required("Повторите пароль"),
});
export default function SignupDialog() {
const [isDialogOpen, setIsDialogOpen] = useState<boolean>(true);
const user = useUserStore((state) => state.user);
const theme = useTheme();
const upMd = useMediaQuery(theme.breakpoints.up("md"));
const navigate = useNavigate();
const location = useLocation();
const formik = useFormik<Values>({
initialValues,
validationSchema,
onSubmit: (values, formikHelpers) => {
makeRequest<RegisterRequest, RegisterResponse>({
url: "https://hub.pena.digital/auth/register",
body: {
login: values.email.trim(),
password: values.password.trim(),
phoneNumber: "+7",
},
useToken: false,
withCredentials: true,
})
.then((result) => {
setUserId(result._id);
})
.catch((error: any) => {
const errorMessage = getMessageFromFetchError(error);
if (errorMessage) enqueueSnackbar(errorMessage);
})
.finally(() => {
formikHelpers.setSubmitting(false);
});
const [isDialogOpen, setIsDialogOpen] = useState<boolean>(true);
const user = useUserStore((state) => state.user);
const theme = useTheme();
const upMd = useMediaQuery(theme.breakpoints.up("md"));
const navigate = useNavigate();
const location = useLocation();
const formik = useFormik<Values>({
initialValues,
validationSchema,
onSubmit: async (values, formikHelpers) => {
const [registerResponse, registerError] = await register(
values.email.trim(),
values.password.trim(),
"+7"
);
formikHelpers.setSubmitting(false);
if (registerError) {
return enqueueSnackbar(registerError);
}
if (registerResponse) {
setUserId(registerResponse._id);
}
},
});
useEffect(
function redirectIfSignedIn() {
if (user) navigate("/tariffs", { replace: true });
},
[navigate, user]
);
function handleClose() {
setIsDialogOpen(false);
setTimeout(() => navigate("/"), theme.transitions.duration.leavingScreen);
}
return (
<Dialog
open={isDialogOpen}
onClose={handleClose}
PaperProps={{
sx: {
width: "600px",
maxWidth: "600px",
},
});
useEffect(
function redirectIfSignedIn() {
if (user) navigate("/tariffs", { replace: true });
}}
slotProps={{
backdrop: {
style: {
backgroundColor: "rgb(0 0 0 / 0.7)",
},
},
[navigate, user]
);
function handleClose() {
setIsDialogOpen(false);
setTimeout(() => navigate("/"), theme.transitions.duration.leavingScreen);
}
return (
<Dialog
open={isDialogOpen}
onClose={handleClose}
PaperProps={{
sx: {
width: "600px",
maxWidth: "600px",
},
}}
slotProps={{
backdrop: {
style: {
backgroundColor: "rgb(0 0 0 / 0.7)",
},
},
}}
}}
>
<Box
component="form"
onSubmit={formik.handleSubmit}
noValidate
sx={{
position: "relative",
backgroundColor: "white",
display: "flex",
alignItems: "center",
flexDirection: "column",
p: upMd ? "50px" : "18px",
pb: upMd ? "40px" : "30px",
gap: "15px",
borderRadius: "12px",
boxShadow: cardShadow,
}}
>
<IconButton
onClick={handleClose}
sx={{
position: "absolute",
right: "7px",
top: "7px",
}}
>
<Box
component="form"
onSubmit={formik.handleSubmit}
noValidate
sx={{
position: "relative",
backgroundColor: "white",
display: "flex",
alignItems: "center",
flexDirection: "column",
p: upMd ? "50px" : "18px",
pb: upMd ? "40px" : "30px",
gap: "15px",
borderRadius: "12px",
boxShadow: cardShadow,
}}
>
<IconButton
onClick={handleClose}
sx={{
position: "absolute",
right: "7px",
top: "7px",
}}
>
<CloseIcon sx={{ transform: "scale(1.5)" }} />
</IconButton>
<Box sx={{ mt: upMd ? undefined : "62px" }}>
<PenaLogo width={upMd ? 233 : 196} color="black" />
</Box>
<Typography
sx={{
color: theme.palette.gray.dark,
mt: "5px",
mb: upMd ? "35px" : "33px",
}}
>
Регистрация
</Typography>
<InputTextfield
TextfieldProps={{
value: formik.values.email,
placeholder: "username",
onBlur: formik.handleBlur,
error: formik.touched.email && Boolean(formik.errors.email),
helperText: formik.touched.email && formik.errors.email,
}}
onChange={formik.handleChange}
color="#F2F3F7"
id="email"
label="Email"
gap={upMd ? "10px" : "10px"}
/>
<PasswordInput
TextfieldProps={{
value: formik.values.password,
placeholder: "Не менее 8 символов",
onBlur: formik.handleBlur,
error: formik.touched.password && Boolean(formik.errors.password),
helperText: formik.touched.password && formik.errors.password,
autoComplete: "new-password",
}}
onChange={formik.handleChange}
color="#F2F3F7"
id="password"
label="Пароль"
gap={upMd ? "10px" : "10px"}
/>
<PasswordInput
TextfieldProps={{
value: formik.values.repeatPassword,
placeholder: "Не менее 8 символов",
onBlur: formik.handleBlur,
error: formik.touched.repeatPassword && Boolean(formik.errors.repeatPassword),
helperText: formik.touched.repeatPassword && formik.errors.repeatPassword,
autoComplete: "new-password",
}}
onChange={formik.handleChange}
color="#F2F3F7"
id="repeatPassword"
label="Повторить пароль"
gap={upMd ? "10px" : "10px"}
/>
<Button
variant="pena-contained-dark"
fullWidth
type="submit"
disabled={formik.isSubmitting}
sx={{
py: "12px",
"&:hover": {
backgroundColor: theme.palette.purple.dark,
},
"&:active": {
color: "white",
backgroundColor: "black",
},
}}
>Зарегистрироваться</Button>
<Link
component={RouterLink}
to="/signin"
state={{ backgroundLocation: location.state.backgroundLocation }}
sx={{
color: theme.palette.purple.main,
mt: "auto",
}}
>
Вход в личный кабинет
</Link>
</Box>
</Dialog>
);
<CloseIcon sx={{ transform: "scale(1.5)" }} />
</IconButton>
<Box sx={{ mt: upMd ? undefined : "62px" }}>
<PenaLogo width={upMd ? 233 : 196} color="black" />
</Box>
<Typography
sx={{
color: theme.palette.gray.dark,
mt: "5px",
mb: upMd ? "35px" : "33px",
}}
>
Регистрация
</Typography>
<InputTextfield
TextfieldProps={{
value: formik.values.email,
placeholder: "username",
onBlur: formik.handleBlur,
error: formik.touched.email && Boolean(formik.errors.email),
helperText: formik.touched.email && formik.errors.email,
}}
onChange={formik.handleChange}
color="#F2F3F7"
id="email"
label="Email"
gap={upMd ? "10px" : "10px"}
/>
<PasswordInput
TextfieldProps={{
value: formik.values.password,
placeholder: "Не менее 8 символов",
onBlur: formik.handleBlur,
error: formik.touched.password && Boolean(formik.errors.password),
helperText: formik.touched.password && formik.errors.password,
autoComplete: "new-password",
}}
onChange={formik.handleChange}
color="#F2F3F7"
id="password"
label="Пароль"
gap={upMd ? "10px" : "10px"}
/>
<PasswordInput
TextfieldProps={{
value: formik.values.repeatPassword,
placeholder: "Не менее 8 символов",
onBlur: formik.handleBlur,
error:
formik.touched.repeatPassword &&
Boolean(formik.errors.repeatPassword),
helperText:
formik.touched.repeatPassword && formik.errors.repeatPassword,
autoComplete: "new-password",
}}
onChange={formik.handleChange}
color="#F2F3F7"
id="repeatPassword"
label="Повторить пароль"
gap={upMd ? "10px" : "10px"}
/>
<Button
variant="pena-contained-dark"
fullWidth
type="submit"
disabled={formik.isSubmitting}
sx={{
py: "12px",
"&:hover": {
backgroundColor: theme.palette.purple.dark,
},
"&:active": {
color: "white",
backgroundColor: "black",
},
}}
>
Зарегистрироваться
</Button>
<Link
component={RouterLink}
to="/signin"
state={{ backgroundLocation: location.state.backgroundLocation }}
sx={{
color: theme.palette.purple.main,
mt: "auto",
}}
>
Вход в личный кабинет
</Link>
</Box>
</Dialog>
);
}

@ -1,36 +1,38 @@
import { devlog, makeRequest } from "@frontend/kitui";
import { ServiceKeyToPrivilegesMap } from "@root/model/privilege";
import { useEffect, useLayoutEffect, useRef } from "react";
import { devlog } from "@frontend/kitui";
import { ServiceKeyToPrivilegesMap } from "@root/model/privilege";
import { getCustomTariffs } from "@root/api/tariff";
export function useCustomTariffs({ onError, onNewUser, url }: {
url: string;
onNewUser: (response: ServiceKeyToPrivilegesMap) => void;
onError: (error: any) => void;
export function useCustomTariffs({
onError,
onNewUser,
}: {
onNewUser: (response: ServiceKeyToPrivilegesMap) => void;
onError: (error: any) => void;
}) {
const onNewUserRef = useRef(onNewUser);
const onErrorRef = useRef(onError);
const onNewUserRef = useRef(onNewUser);
const onErrorRef = useRef(onError);
useLayoutEffect(() => {
onNewUserRef.current = onNewUser;
onErrorRef.current = onError;
});
useLayoutEffect(() => {
onNewUserRef.current = onNewUser;
onErrorRef.current = onError;
});
useEffect(() => {
const controller = new AbortController();
useEffect(() => {
const controller = new AbortController();
makeRequest<never, ServiceKeyToPrivilegesMap>({
url,
signal: controller.signal,
method: "get",
useToken: true,
}).then(result => {
onNewUserRef.current(result);
}).catch(error => {
devlog("Error fetching custom tariffs", error);
onErrorRef.current(error);
});
getCustomTariffs(controller.signal)
.then(([customTariffs]) => {
if (customTariffs) {
onNewUserRef.current(customTariffs);
}
})
.catch(([_, error]) => {
devlog("Error fetching custom tariffs", error);
onErrorRef.current(error);
});
return () => controller.abort();
}, [url]);
return () => controller.abort();
}, []);
}

@ -1,36 +1,38 @@
import { Discount, devlog, makeRequest } from "@frontend/kitui";
import { GetDiscountsResponse } from "@root/model/discount";
import { useEffect, useLayoutEffect, useRef } from "react";
import { Discount, devlog } from "@frontend/kitui";
import { getDiscounts } from "@root/api/price";
export function useDiscounts({ url = "https://admin.pena.digital/price/discounts", onNewDiscounts, onError }: {
url?: string;
onNewDiscounts: (response: Discount[]) => void;
onError: (error: Error) => void;
export function useDiscounts({
onNewDiscounts,
onError,
}: {
url?: string;
onNewDiscounts: (response: Discount[]) => void;
onError: (error: Error) => void;
}) {
const onNewTariffsRef = useRef(onNewDiscounts);
const onErrorRef = useRef(onError);
const onNewTariffsRef = useRef(onNewDiscounts);
const onErrorRef = useRef(onError);
useLayoutEffect(() => {
onNewTariffsRef.current = onNewDiscounts;
onErrorRef.current = onError;
}, [onError, onNewDiscounts]);
useLayoutEffect(() => {
onNewTariffsRef.current = onNewDiscounts;
onErrorRef.current = onError;
}, [onError, onNewDiscounts]);
useEffect(() => {
const controller = new AbortController();
useEffect(() => {
const controller = new AbortController();
makeRequest<never, GetDiscountsResponse>({
url,
method: "get",
useToken: true,
signal: controller.signal,
}).then((result) => {
onNewTariffsRef.current(result.Discounts);
}).catch(error => {
devlog("Error fetching tariffs", error);
onErrorRef.current(error);
});
getDiscounts(controller.signal)
.then(([discounts]) => {
if (discounts) {
onNewTariffsRef.current(discounts.Discounts);
}
})
.catch((error) => {
devlog("Error fetching tariffs", error);
onErrorRef.current(error);
});
return () => controller.abort();
}, [url]);
return () => controller.abort();
}, []);
}

@ -1,50 +1,49 @@
import { Tariff, makeRequest } from "@frontend/kitui";
import { GetTariffsResponse } from "@root/model/tariff";
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import { getTariffs } from "@root/api/tariff";
import type { Tariff } from "@frontend/kitui";
export function useTariffFetcher({
baseUrl = process.env.NODE_ENV === "production" ? "/strator/tariff" : "https://hub.pena.digital/strator/tariff",
tariffsPerPage,
apiPage,
onSuccess,
onError,
tariffsPerPage,
apiPage,
onSuccess,
onError,
}: {
baseUrl?: string;
tariffsPerPage: number;
apiPage: number;
onSuccess: (response: Tariff[]) => void;
onError?: (error: Error) => void;
baseUrl?: string;
tariffsPerPage: number;
apiPage: number;
onSuccess: (response: Tariff[]) => void;
onError?: (error: Error) => void;
}) {
const [fetchState, setFetchState] = useState<"fetching" | "idle" | "all fetched">("idle");
const onSuccessRef = useRef(onSuccess);
const onErrorRef = useRef(onError);
const [fetchState, setFetchState] = useState<
"fetching" | "idle" | "all fetched"
>("idle");
const onSuccessRef = useRef(onSuccess);
const onErrorRef = useRef(onError);
useLayoutEffect(() => {
onSuccessRef.current = onSuccess;
onErrorRef.current = onError;
}, [onError, onSuccess]);
useLayoutEffect(() => {
onSuccessRef.current = onSuccess;
onErrorRef.current = onError;
}, [onError, onSuccess]);
useEffect(() => {
const controller = new AbortController();
useEffect(() => {
const controller = new AbortController();
setFetchState("fetching");
makeRequest<never, GetTariffsResponse>({
url: baseUrl + `?page=${apiPage}&limit=${tariffsPerPage}`,
method: "get",
useToken: true,
signal: controller.signal,
}).then((result) => {
if (result.tariffs.length > 0) {
onSuccessRef.current(result.tariffs);
setFetchState("idle");
} else setFetchState("all fetched");
}).catch(error => {
onErrorRef.current?.(error);
});
setFetchState("fetching");
getTariffs(apiPage, tariffsPerPage, controller.signal)
.then(([result]) => {
if (result && result.tariffs.length > 0) {
onSuccessRef.current(result.tariffs);
setFetchState("idle");
} else setFetchState("all fetched");
})
.catch(([_, error]) => {
onErrorRef.current?.(error);
});
return () => controller.abort();
}, [apiPage, tariffsPerPage, baseUrl]);
return () => controller.abort();
}, [apiPage, tariffsPerPage]);
return fetchState;
return fetchState;
}