Merge branch 'WIP' into dev

This commit is contained in:
nflnkr 2023-06-02 11:27:28 +03:00
commit 5ccad90f79
22 changed files with 1061 additions and 377 deletions

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

28
src/api/user.ts Normal file

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

@ -2,43 +2,45 @@ import { Typography, useTheme } from "@mui/material";
import { useNavigate } from "react-router-dom";
interface Props {
text1: string;
text2: string;
text1: string;
text2?: string;
}
export default function ComplexNavText({ text1, text2 }: Props) {
const theme = useTheme();
const navigate = useNavigate();
const theme = useTheme();
const navigate = useNavigate();
return (
<Typography component="div" sx={{ display: "flex" }}>
<Typography
component="div"
onClick={() => navigate("/tariffs")}
sx={{
cursor: "pointer",
fontWeight: 400,
fontSize: "12px",
lineHeight: "14px",
color: theme.palette.grey2.main,
}}
>
{text1}
</Typography>
<Typography
component="span"
sx={{
cursor: "default",
fontWeight: 400,
fontSize: "12px",
lineHeight: "14px",
color: theme.palette.fadePurple.main,
textUnderlinePosition: "under",
textDecorationColor: theme.palette.brightPurple.main,
}}
>
{text2}
</Typography>
</Typography>
);
return (
<Typography component="div" sx={{ display: "flex" }}>
<Typography
component="div"
onClick={() => navigate("/tariffs")}
sx={{
cursor: "pointer",
fontWeight: 400,
fontSize: "12px",
lineHeight: "14px",
color: theme.palette.grey2.main,
}}
>
{text1}
</Typography>
{text2 &&
<Typography
component="span"
sx={{
cursor: "default",
fontWeight: 400,
fontSize: "12px",
lineHeight: "14px",
color: theme.palette.fadePurple.main,
textUnderlinePosition: "under",
textDecorationColor: theme.palette.brightPurple.main,
}}
>
{text2}
</Typography>
}
</Typography>
);
}

@ -0,0 +1,45 @@
import { Button, ButtonProps, SxProps, Theme, useMediaQuery, useTheme } from "@mui/material";
import { MouseEventHandler, ReactNode } from "react";
interface Props {
icon?: ReactNode;
ButtonProps?: ButtonProps;
children?: ReactNode;
sx?: SxProps<Theme>;
onClick?: MouseEventHandler<HTMLButtonElement>;
}
export default function UnderlinedButtonWithIcon({ ButtonProps, icon, children, sx, onClick }: Props) {
const theme = useTheme();
const upMd = useMediaQuery(theme.breakpoints.up("md"));
return (
<Button
variant="text"
startIcon={icon}
disableTouchRipple
sx={{
p: 0,
fontWeight: 400,
fontSize: upMd ? "18px" : "16px",
lineHeight: "21px",
textDecorationLine: "underline",
color: "#7E2AEA",
textAlign: "start",
textUnderlineOffset: "2px",
"& .MuiButton-startIcon": {
alignSelf: "start",
},
"&:hover": {
backgroundColor: "rgb(0 0 0 / 0)",
},
...sx,
}}
onClick={onClick}
{...ButtonProps}
>
{children}
</Button>
);
}

@ -0,0 +1,21 @@
import { Box } from "@mui/material";
export default function CloseSmallIcon() {
return (
<Box sx={{
width: "24px",
height: "24px",
display: "flex",
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
}}>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M18 6L6 18" stroke="#A9AAB1" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
<path d="M6 6L18 18" stroke="#A9AAB1" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</Box>
);
}

@ -0,0 +1,21 @@
import { Box } from "@mui/material";
export default function EyeIcon() {
return (
<Box sx={{
width: "24px",
height: "24px",
display: "flex",
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
}}>
<svg width="22" height="15" viewBox="0 0 22 15" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10.9502 14C16.3669 14 19.6169 9.66667 20.7002 7.5C19.6169 5.33333 16.3669 1 10.9502 1C5.53353 1 2.28353 5.33333 1.2002 7.5C2.64464 9.66667 5.53353 14 10.9502 14Z" stroke="#7E2AEA" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
<circle cx="10.9495" cy="7.50033" r="3.58333" stroke="#7E2AEA" strokeWidth="1.5" />
</svg>
</Box>
);
}

@ -0,0 +1,24 @@
import { Box } from "@mui/material";
interface Props {
color?: string;
}
export default function PaperClipIcon({ color = "#7E2AEA" }: Props) {
return (
<Box sx={{
width: "24px",
height: "24px",
display: "flex",
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
}}>
<svg width="17" height="19" viewBox="0 0 17 19" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M15.7541 9.80636C13.714 11.8464 9.42986 16.1306 8.61382 16.9467C7.59378 17.9667 5.14568 19.0548 3.10559 17.0147C1.0655 14.9746 1.47352 12.5265 2.83358 11.1664C4.19364 9.80636 10.9939 3.00608 11.674 2.32605C12.694 1.30601 14.1901 1.44201 15.2101 2.46205C16.2301 3.4821 16.5686 4.91167 15.4141 6.06621C14.0541 7.42626 7.45777 14.1585 6.77775 14.8386C6.09772 15.5186 5.31107 15.276 4.90767 14.8726C4.50426 14.4692 4.26164 13.6825 4.94167 13.0025C5.48569 12.4585 9.79254 8.15163 11.946 5.9982" stroke={color} strokeWidth="1.5" strokeLinecap="round" />
</svg>
</Box>
);
}

@ -0,0 +1,22 @@
import { Box } from "@mui/material";
export default function UploadIcon() {
return (
<Box sx={{
width: "24px",
height: "24px",
display: "flex",
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
}}>
<svg width="24" height="25" viewBox="0 0 24 25" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8.0625 7.80957L12 3.80957L15.9375 7.80957" stroke="#7E2AEA" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
<path d="M12 14.4762V3.80957" stroke="#7E2AEA" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
<path d="M20.25 14.4761V19.8094C20.25 20.0115 20.171 20.2053 20.0303 20.3482C19.8897 20.491 19.6989 20.5713 19.5 20.5713H4.5C4.30109 20.5713 4.11032 20.491 3.96967 20.3482C3.82902 20.2053 3.75 20.0115 3.75 19.8094V14.4761" stroke="#7E2AEA" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</Box>
);
}

@ -6,7 +6,7 @@ import Faq from "./pages/Faq/Faq";
import Wallet from "./pages/Wallet";
import Payment from "./pages/Payment/Payment";
import Support from "./pages/Support/Support";
import AccountSetup from "./pages/AccountSetup";
import AccountSetup from "./pages/AccountSetup/AccountSetup";
import Landing from "./pages/Landing/Landing";
import Tariffs from "./pages/Tariffs/Tariffs";
import SigninDialog from "./pages/auth/Signin";
@ -20,7 +20,7 @@ import reportWebVitals from "./reportWebVitals";
import { SnackbarProvider, enqueueSnackbar } from "notistack";
import "./index.css";
import Layout from "./components/Layout";
import { getUser } from "./api/auth";
import { getUser } from "./api/user";
import { setUser, useUserStore } from "./stores/user";
import TariffConstructor from "./pages/TariffConstructor/TariffConstructor";

@ -1,5 +1,3 @@
export interface RegisterRequest {
login: string;
password: string;
@ -21,14 +19,3 @@ export interface LoginRequest {
}
export type LoginResponse = RegisterResponse;
export interface User {
_id: string;
login: string;
email: string;
phoneNumber: string;
isDeleted: boolean;
createdAt: string;
updatedAt: string;
deletedAt?: string;
}

44
src/model/user.ts Normal file

@ -0,0 +1,44 @@
export interface User {
_id: string;
login: string;
email: string;
phoneNumber: string;
isDeleted: boolean;
createdAt: string;
updatedAt: string;
deletedAt?: string;
}
export type UserWithFields = User & Partial<Record<UserSettingsField, string>>;
export type UserSettingsField =
| "password"
| "email"
| "phoneNumber"
| "name"
| "surname"
| "middleName"
| "companyName";
export type UserSettings = Record<UserSettingsField, {
value: string;
error: string | null;
touched: boolean;
}>;
export type PatchUserRequest = Partial<Record<UserSettingsField, string>>;
export type VerificationStatus = "verificated" | "notVerificated" | "waiting";
export type UserDocument = {
file: File | null;
uploadedFileName: string | null;
imageSrc: string | null;
};
export type UserDocumentTypes =
| "ИНН"
| "Устав"
| "Свидетельство о регистрации НКО";
export type UserDocuments = Record<UserDocumentTypes, UserDocument>;

@ -1,272 +0,0 @@
import { Box, Button, Link, Typography, useMediaQuery, useTheme } from "@mui/material";
import CustomButton from "@components/CustomButton";
import InputTextfield from "@components/InputTextfield";
import SectionWrapper from "@components/SectionWrapper";
import Download from "../assets/Icons/Download.svg";
import Account from "../assets/Icons/Account.svg";
import { useState } from "react";
export default function AccountSetup() {
const theme = useTheme();
const upMd = useMediaQuery(theme.breakpoints.up("md"));
const [name, setName] = useState<string>("");
const [email, setEmail] = useState<string>("");
const [surname, setSurname] = useState<string>("");
const [telephone, setTelephone] = useState<string>("");
const [otchestvo, setOtchestvo] = useState<string>("");
const [password, setPassword] = useState<string>("");
const [сompanyName, setCompanyName] = useState<string>("");
const [avatar, setAvatar] = useState<any>();
const imgHC = (imgInp: any) => {
if (imgInp.target.files !== null) {
const file = imgInp.target.files[0];
setAvatar(URL.createObjectURL(file));
handleClose();
}
};
return (
<SectionWrapper
maxWidth="lg"
sx={{
mt: "25px",
mb: "70px",
}}
>
<Typography
component="div"
sx={{ fontWeight: "400px", fontSize: "12px", lineHeight: "14px", marginBottom: "19px" }}
>
Настройки аккаунта
</Typography>
<Box
sx={{
mt: "20px",
mb: "40px",
display: "flex",
gap: "10px",
}}
>
<Typography variant="h4">Настройки аккаунта</Typography>
</Box>
<Box
sx={{
width: "100%",
backgroundColor: "white",
display: "flex",
flexDirection: upMd ? "row" : "column",
borderRadius: "12px",
mb: "40px",
boxShadow: `0px 100px 309px rgba(210, 208, 225, 0.24),
0px 41.7776px 129.093px rgba(210, 208, 225, 0.172525),
0px 22.3363px 69.0192px rgba(210, 208, 225, 0.143066),
0px 12.5216px 38.6916px rgba(210, 208, 225, 0.12),
0px 6.6501px 20.5488px rgba(210, 208, 225, 0.0969343),
0px 2.76726px 8.55082px rgba(210, 208, 225, 0.0674749)`,
}}
>
<Box sx={{ display: "flex", justifyContent: "left", ml: "20px", mt: "20px", mr: "58px" }}>
<Typography component="div">
<Box component="image" sx={{ display: "flex", width: "220px", height: "220px", borderRadius: "8px" }}>
<img src={avatar ? avatar : Account} alt="Account" />
</Box>
<Button
variant="outlined"
component="label"
sx={{
width: "220px",
paddingTop: "10px",
paddingBottom: "10px",
borderRadius: "8px",
boxShadow: "none",
color: theme.palette.brightPurple.main,
borderColor: theme.palette.brightPurple.main,
mt: "30px",
}}
>
Загрузить фото
<input type="file" hidden onChange={(event) => imgHC(event)} />
</Button>
</Typography>
</Box>
<Box
sx={{
display: "flex",
flexWrap: "wrap",
mt: "10px",
mb: "10px",
mr: "20px",
}}
>
<Box
component="div"
sx={{
display: "flex",
justifyContent: "space-between",
flexWrap: "wrap",
columnGap: "30px",
ml: "20px",
mt: "20px",
}}
>
<InputTextfield
TextfieldProps={{
placeholder: "Имя",
}}
onChange={(event) => setName(event.target.value)}
id="text"
label="Имя"
gap={upMd ? "15px" : "10px"}
color={name ? "#e8badd" : ""}
FormInputSx={{ width: upMd ? "45%" : "100%", mt: "19px", maxWidth: "406px" }}
/>
<InputTextfield
TextfieldProps={{
placeholder: "username@penahaub.com",
type: "text",
}}
onChange={(event) => setEmail(event.target.value)}
id="email"
label="E-mail"
gap={upMd ? "15px" : "10px"}
FormInputSx={{ width: upMd ? "45%" : "100%", mt: "19px", maxWidth: "406px" }}
color={email ? "#e8badd" : ""}
/>
<InputTextfield
TextfieldProps={{
placeholder: "Фамилия",
type: "text",
}}
onChange={(event) => setSurname(event.target.value)}
id="password"
label="Фамилия"
gap={upMd ? "15px" : "10px"}
FormInputSx={{ width: upMd ? "45%" : "100%", mt: "19px", maxWidth: "406px" }}
color={surname ? "#e8badd" : ""}
/>
<InputTextfield
TextfieldProps={{
placeholder: "+7 900 000 00 00",
type: "text",
}}
onChange={(enent) => setTelephone(enent.target.value)}
id="password"
label="Телефон"
gap={upMd ? "15px" : "10px"}
FormInputSx={{ width: upMd ? "45%" : "100%", mt: "19px", maxWidth: "406px" }}
color={telephone ? "#e8badd" : ""}
/>
<InputTextfield
TextfieldProps={{
placeholder: "Отчество",
type: "text",
}}
onChange={(enent) => setOtchestvo(enent.target.value)}
id="password"
label="Отчество"
gap={upMd ? "15px" : "10px"}
FormInputSx={{ width: upMd ? "45%" : "100%", mt: "19px", maxWidth: "406px" }}
color={otchestvo ? "#e8badd" : ""}
/>
<InputTextfield
TextfieldProps={{
placeholder: "Не мение 8 символов",
type: "password",
}}
onChange={(enent) => setPassword(enent.target.value)}
id="email"
label="Пароль"
gap={upMd ? "15px" : "10px"}
FormInputSx={{ width: upMd ? "45%" : "100%", mt: "19px", maxWidth: "406px" }}
color={password ? "#e8badd" : ""}
/>
<InputTextfield
TextfieldProps={{
placeholder: "ООО Фирма",
type: "text",
}}
onChange={(enent) => setCompanyName(enent.target.value)}
id="text"
label="Название компании"
gap={upMd ? "15px" : "10px"}
FormInputSx={{ width: upMd ? "45%" : "100%", mt: "19px", maxWidth: "406px" }}
color={сompanyName ? " #e8badd" : ""}
/>
<Box
sx={{
margin: 0,
width: upMd ? "45%" : "100%",
padding: 0,
display: "flex",
flexDirection: "column",
justifyContent: "center",
maxWidth: "406px",
}}
>
<Typography sx={{ display: "flex", fontSize: upMd ? "16px" : "18px" }}>
<img src={Download} alt="Download" />
<Link
href="#"
sx={{
color: theme.palette.brightPurple.main,
marginLeft: "11px",
}}
>
Загрузить документы для юр лиц
</Link>
</Typography>
<Typography sx={{ display: "flex", fontSize: upMd ? "16px" : "18px" }}>
<img src={Download} alt="Download" />
<Link
href="#"
sx={{
color: theme.palette.brightPurple.main,
marginLeft: "11px",
marginTop: "5px",
}}
>
Загрузить документы для НКО
</Link>
</Typography>
</Box>
<Typography component="div" sx={{ width: "100%", display: "flex", justifyContent: "right" }}>
<CustomButton
variant="contained"
sx={{
width: "180px",
height: "44px",
backgroundColor: theme.palette.brightPurple.main,
textColor: "white",
mb: "20px",
}}
>
Cохранить
</CustomButton>
</Typography>
</Box>
</Box>
</Box>
</SectionWrapper>
);
}
function handleClose() {
throw new Error("Function not implemented.");
}

@ -0,0 +1,246 @@
import { Box, SxProps, Theme, Typography, useMediaQuery, useTheme } from "@mui/material";
import CustomButton from "@components/CustomButton";
import InputTextfield from "@components/InputTextfield";
import SectionWrapper from "@components/SectionWrapper";
import ComplexNavText from "@root/components/ComplexNavText";
import { openDocumentsDialog, sendUserData, setSettingsField, useUserStore } from "@root/stores/user";
import UnderlinedButtonWithIcon from "@root/components/UnderlinedButtonWithIcon";
import UploadIcon from "@root/components/icons/UploadIcon";
import { VerificationStatus } from "@root/model/user";
import DocumentsDialog from "./DocumentsDialog/DocumentsDialog";
import EyeIcon from "@root/components/icons/EyeIcon";
export default function AccountSetup() {
const theme = useTheme();
const upMd = useMediaQuery(theme.breakpoints.up("md"));
const upSm = useMediaQuery(theme.breakpoints.up("sm"));
const fields = useUserStore(state => state.settingsFields);
const verificationStatus = useUserStore(state => state.verificationStatus);
const verificationType = useUserStore(state => state.verificationType);
const textFieldProps = {
gap: upMd ? "16px" : "10px",
color: "#F2F3F7",
bold: true,
};
return (
<SectionWrapper
maxWidth="lg"
sx={{
mt: "25px",
mb: "70px",
}}
>
<DocumentsDialog />
<ComplexNavText text1="Настройки аккаунта" />
<Typography variant="h4" mt="20px">Настройки аккаунта</Typography>
<Box sx={{
mt: "40px",
mb: "40px",
backgroundColor: "white",
display: "flex",
flexDirection: "column",
borderRadius: "12px",
p: "20px",
gap: "40px",
boxShadow: `
0px 100px 309px rgba(210, 208, 225, 0.24),
0px 41.7776px 129.093px rgba(210, 208, 225, 0.172525),
0px 22.3363px 69.0192px rgba(210, 208, 225, 0.143066),
0px 12.5216px 38.6916px rgba(210, 208, 225, 0.12),
0px 6.6501px 20.5488px rgba(210, 208, 225, 0.0969343),
0px 2.76726px 8.55082px rgba(210, 208, 225, 0.0674749)
`,
}}>
<Box sx={{
display: "flex",
gap: "31px",
justifyContent: "space-between",
flexDirection: upMd ? "row" : "column",
}}>
<Box sx={{
display: "grid",
gridAutoFlow: upSm ? "column" : "row",
gridTemplateRows: "repeat(4, auto)",
rowGap: "15px",
columnGap: "31px",
flexGrow: 1,
}}>
<InputTextfield
TextfieldProps={{
placeholder: "Имя",
value: fields?.name.value || "",
helperText: fields?.name.touched && fields.name.error,
error: fields?.name.touched && Boolean(fields.name.error),
}}
onChange={e => setSettingsField("name", e.target.value)}
id="name"
label="Имя"
{...textFieldProps}
/>
<InputTextfield
TextfieldProps={{
placeholder: "Фамилия",
value: fields?.surname.value || "",
helperText: fields?.surname.touched && fields.surname.error,
error: fields?.surname.touched && Boolean(fields.surname.error),
}}
onChange={e => setSettingsField("surname", e.target.value)}
id="surname"
label="Фамилия"
{...textFieldProps}
/>
<InputTextfield
TextfieldProps={{
placeholder: "Отчество",
value: fields?.middleName.value || "",
helperText: fields?.middleName.touched && fields.middleName.error,
error: fields?.middleName.touched && Boolean(fields.middleName.error),
}}
onChange={e => setSettingsField("middleName", e.target.value)}
id="middleName"
label="Отчество"
{...textFieldProps}
/>
<InputTextfield
TextfieldProps={{
placeholder: "ООО Фирма",
value: fields?.companyName.value || "",
helperText: fields?.companyName.touched && fields.companyName.error,
error: fields?.companyName.touched && Boolean(fields.companyName.error),
}}
onChange={e => setSettingsField("companyName", e.target.value)}
id="companyName"
label="Название компании"
{...textFieldProps}
/>
<InputTextfield
TextfieldProps={{
placeholder: "username@penahaub.com",
value: fields?.email.value || "",
helperText: fields?.email.touched && fields.email.error,
error: fields?.email.touched && Boolean(fields.email.error),
}}
onChange={e => setSettingsField("email", e.target.value)}
id="email"
label="E-mail"
{...textFieldProps}
/>
<InputTextfield
TextfieldProps={{
placeholder: "+7 900 000 00 00",
value: fields?.phoneNumber.value || "",
helperText: fields?.phoneNumber.touched && fields.phoneNumber.error,
error: fields?.phoneNumber.touched && Boolean(fields.phoneNumber.error),
}}
onChange={e => setSettingsField("phoneNumber", e.target.value)}
id="phoneNumber"
label="Телефон"
{...textFieldProps}
/>
<InputTextfield
TextfieldProps={{
placeholder: "Не менее 8 символов",
value: fields?.password.value || "",
helperText: fields?.password.touched && fields.password.error,
error: fields?.password.touched && Boolean(fields.password.error),
type: "password",
}}
onChange={e => setSettingsField("password", e.target.value)}
id="password"
label="Пароль"
{...textFieldProps}
/>
</Box>
<Box sx={{
maxWidth: "246px",
}}>
<Typography variant="p1">Статус</Typography>
<VerificationIndicator
verificationStatus={verificationStatus}
sx={{ mt: "16px" }}
/>
{verificationStatus === "notVerificated" &&
<>
<UnderlinedButtonWithIcon
icon={<UploadIcon />}
sx={{ mt: "55px" }}
ButtonProps={{
onClick: () => openDocumentsDialog("juridical"),
}}
>Загрузить документы для юр лиц</UnderlinedButtonWithIcon>
<UnderlinedButtonWithIcon
icon={<UploadIcon />}
sx={{ mt: "15px" }}
ButtonProps={{
onClick: () => openDocumentsDialog("nko"),
}}
>Загрузить документы для НКО</UnderlinedButtonWithIcon>
</>
}
{verificationStatus === "verificated" &&
<UnderlinedButtonWithIcon
icon={<EyeIcon />}
sx={{ mt: "55px" }}
ButtonProps={{
onClick: () => openDocumentsDialog(verificationType),
}}
>Посмотреть свою верификацию</UnderlinedButtonWithIcon>
}
</Box>
</Box>
<CustomButton
onClick={sendUserData}
variant="contained"
sx={{
width: "180px",
height: "44px",
alignSelf: "end",
backgroundColor: theme.palette.brightPurple.main,
textColor: "white",
}}
>
Сохранить
</CustomButton>
</Box>
</SectionWrapper>
);
}
const verificationStatusData: Record<VerificationStatus, { text: string; color: string; }> = {
"verificated": {
text: "Верификация пройдена",
color: "#0D9F00",
},
"waiting": {
text: "В ожидании верификации",
color: "#F18956",
},
"notVerificated": {
text: "Не верифицирован",
color: "#E02C2C",
},
};
function VerificationIndicator({ verificationStatus, sx }: {
verificationStatus: VerificationStatus;
sx?: SxProps<Theme>;
}) {
return (
<Box sx={{
py: "14px",
px: "8.5px",
borderWidth: "1px",
borderStyle: "solid",
color: verificationStatusData[verificationStatus].color,
borderColor: verificationStatusData[verificationStatus].color,
borderRadius: "8px",
textAlign: "center",
...sx,
}}>
<Typography lineHeight="100%">{verificationStatusData[verificationStatus].text}</Typography>
</Box>
);
}

@ -0,0 +1,45 @@
import { Box, SxProps, Theme, Typography, useTheme } from "@mui/material";
import { UserDocument } from "@root/model/user";
interface Props {
text: string;
document: UserDocument;
sx?: SxProps<Theme>;
}
export default function DocumentItem({ text, document, sx }: Props) {
const theme = useTheme();
return (
<Box sx={{
display: "flex",
flexDirection: "column",
alignItems: "start",
gap: "10px",
...sx,
}}>
<Typography sx={{
color: "#4D4D4D",
fontWeight: 500,
fontVariantNumeric: "tabular-nums",
}}>{text}</Typography>
{document.uploadedFileName &&
<Typography sx={{
color: theme.palette.brightPurple.main,
}}>{document.uploadedFileName}</Typography>
}
{document.imageSrc &&
<img
src={document.imageSrc}
alt="document"
style={{
maxWidth: "80px",
maxHeight: "200px",
objectFit: "contain",
display: "block",
}}
/>}
</Box>
);
}

@ -0,0 +1,62 @@
import { Box, SxProps, Theme, Typography } from "@mui/material";
import UnderlinedButtonWithIcon from "@root/components/UnderlinedButtonWithIcon";
import PaperClipIcon from "@root/components/icons/PaperClipIcon";
import { UserDocument } from "@root/model/user";
import { ChangeEvent, useRef } from "react";
interface Props {
text: string;
document: UserDocument;
onFileChange: (event: ChangeEvent<HTMLInputElement>) => void;
sx?: SxProps<Theme>;
}
export default function DocumentUploadItem({ text, document, onFileChange, sx }: Props) {
const fileInputRef = useRef<HTMLInputElement>(null);
function handleChooseFileClick() {
fileInputRef.current?.click();
}
return (
<Box sx={{
display: "flex",
flexDirection: "column",
alignItems: "start",
gap: "10px",
...sx,
}}>
<Typography sx={{
color: "#4D4D4D",
fontWeight: 500,
fontVariantNumeric: "tabular-nums",
}}>{text}</Typography>
<UnderlinedButtonWithIcon
icon={<PaperClipIcon />}
onClick={handleChooseFileClick}
>{document.file ? document.file.name : "Выберите файл"}</UnderlinedButtonWithIcon>
<input
ref={fileInputRef}
style={{ display: "none" }}
onChange={onFileChange}
type="file"
id="image-file"
multiple
accept="image/*"
/>
{document.imageSrc &&
<img
src={document.imageSrc}
alt="document"
style={{
maxWidth: "80px",
maxHeight: "200px",
objectFit: "contain",
display: "block",
}}
/>
}
</Box>
);
}

@ -0,0 +1,10 @@
import { useUserStore } from "@root/stores/user";
import NkoDocumentsDialog from "./NkoDocumentsDialog";
import JuridicalDocumentsDialog from "./JuridicalDocumentsDialog";
export default function DocumentsDialog() {
const type = useUserStore(state => state.dialogType);
return type === "juridical" ? <JuridicalDocumentsDialog /> : <NkoDocumentsDialog />
}

@ -0,0 +1,107 @@
import { Box, Dialog, IconButton, Typography, useTheme } from "@mui/material";
import CustomButton from "@root/components/CustomButton";
import CloseSmallIcon from "@root/components/icons/CloseSmallIcon";
import { closeDocumentsDialog, sendDocuments, setDocument, useUserStore } from "@root/stores/user";
import DocumentUploadItem from "./DocumentUploadItem";
import DocumentItem from "./DocumentItem";
export default function JuridicalDocumentsDialog() {
const theme = useTheme();
const isOpen = useUserStore(state => state.isDocumentsDialogOpen);
const verificationStatus = useUserStore(state => state.verificationStatus);
const documents = useUserStore(state => state.documents);
const documentElements = verificationStatus === "verificated" ? (
<>
<DocumentItem
text="1. Скан ИНН организации НКО (выписка из ЕГЮРЛ)"
document={documents["ИНН"]}
/>
<DocumentItem
text="2. Устав организации"
document={documents["Устав"]}
/>
</>
) : (
<>
<DocumentUploadItem
document={documents["ИНН"]}
text="1. Скан ИНН организации (выписка из ЕГЮРЛ)"
onFileChange={e => setDocument("ИНН", e.target?.files?.[0])}
/>
<DocumentUploadItem
document={documents["Устав"]}
text="2. Устав организации"
onFileChange={e => setDocument("Устав", e.target?.files?.[0])}
/>
</>
);
return (
<Dialog
open={isOpen}
onClose={closeDocumentsDialog}
PaperProps={{
sx: {
width: "600px",
maxWidth: "600px",
backgroundColor: "white",
position: "relative",
display: "flex",
flexDirection: "column",
p: "20px",
gap: "20px",
borderRadius: "12px",
boxShadow: "none",
}
}}
slotProps={{ backdrop: { style: { backgroundColor: "rgb(0 0 0 / 0.7)" } } }}
>
<IconButton
onClick={closeDocumentsDialog}
sx={{
position: "absolute",
right: "7px",
top: "7px",
}}
>
<CloseSmallIcon />
</IconButton>
<Box sx={{
p: "20px",
}}>
<Typography variant="h5" lineHeight="100%">
{verificationStatus === "verificated" ? "Ваши документы" : "Загрузите документы"}
</Typography>
<Typography sx={{
fontWeight: 400,
fontSize: "16px",
lineHeight: "100%",
mt: "12px",
}}>для верификации юридических лиц в формате PDF</Typography>
<Box sx={{
mt: "30px",
display: "flex",
flexDirection: "column",
gap: "25px",
}}>
{documentElements}
</Box>
</Box>
<CustomButton
onClick={sendDocuments}
variant="contained"
sx={{
width: "180px",
height: "44px",
alignSelf: "end",
backgroundColor: theme.palette.brightPurple.main,
textColor: "white",
}}
>
Отправить
</CustomButton>
</Dialog>
);
}

@ -0,0 +1,116 @@
import { Box, Dialog, IconButton, Typography, useTheme } from "@mui/material";
import CustomButton from "@root/components/CustomButton";
import CloseSmallIcon from "@root/components/icons/CloseSmallIcon";
import { closeDocumentsDialog, sendDocuments, setDocument, useUserStore } from "@root/stores/user";
import DocumentUploadItem from "./DocumentUploadItem";
import DocumentItem from "./DocumentItem";
export default function NkoDocumentsDialog() {
const theme = useTheme();
const isOpen = useUserStore(state => state.isDocumentsDialogOpen);
const verificationStatus = useUserStore(state => state.verificationStatus);
const documents = useUserStore(state => state.documents);
const documentElements = verificationStatus === "verificated" ? (
<>
<DocumentItem
text="1. Свидетельство о регистрации НКО"
document={documents["Свидетельство о регистрации НКО"]}
/>
<DocumentItem
text="2. Скан ИНН организации НКО (выписка из ЕГЮРЛ)"
document={documents["ИНН"]}
/>
<DocumentItem
text="3. Устав организации"
document={documents["Устав"]}
/>
</>
) : (
<>
<DocumentUploadItem
text="1. Свидетельство о регистрации НКО"
document={documents["Свидетельство о регистрации НКО"]}
onFileChange={e => setDocument("Свидетельство о регистрации НКО", e.target?.files?.[0])}
/>
<DocumentUploadItem
text="2. Скан ИНН организации НКО (выписка из ЕГЮРЛ)"
document={documents["ИНН"]}
onFileChange={e => setDocument("ИНН", e.target?.files?.[0])}
/>
<DocumentUploadItem
text="3. Устав организации"
document={documents["Устав"]}
onFileChange={e => setDocument("Устав", e.target?.files?.[0])}
/>
</>
);
return (
<Dialog
open={isOpen}
onClose={closeDocumentsDialog}
PaperProps={{
sx: {
width: "600px",
maxWidth: "600px",
backgroundColor: "white",
position: "relative",
display: "flex",
flexDirection: "column",
p: "20px",
gap: "20px",
borderRadius: "12px",
boxShadow: "none",
}
}}
slotProps={{ backdrop: { style: { backgroundColor: "rgb(0 0 0 / 0.7)" } } }}
>
<IconButton
onClick={closeDocumentsDialog}
sx={{
position: "absolute",
right: "7px",
top: "7px",
}}
>
<CloseSmallIcon />
</IconButton>
<Box sx={{
p: "20px",
}}>
<Typography variant="h5" lineHeight="100%">
{verificationStatus === "verificated" ? "Ваши документы" : "Загрузите документы"}
</Typography>
<Typography sx={{
fontWeight: 400,
fontSize: "16px",
lineHeight: "100%",
mt: "12px",
}}>для верификации НКО в формате PDF</Typography>
<Box sx={{
mt: "30px",
display: "flex",
flexDirection: "column",
gap: "25px",
}}>
{documentElements}
</Box>
</Box>
<CustomButton
onClick={sendDocuments}
variant="contained"
sx={{
width: "180px",
height: "44px",
alignSelf: "end",
backgroundColor: theme.palette.brightPurple.main,
textColor: "white",
}}
>
Отправить
</CustomButton>
</Dialog>
);
}

@ -44,8 +44,8 @@ export default function SigninDialog() {
makeRequest<LoginRequest, LoginResponse>({
url: "https://hub.pena.digital/auth/login",
body: {
login: values.login,
password: values.password,
login: values.login.trim(),
password: values.password.trim(),
},
useToken: false,
withCredentials: true,
@ -76,38 +76,39 @@ export default function SigninDialog() {
setTimeout(() => navigate("/"), theme.transitions.duration.leavingScreen);
}
return (
<Dialog
open={isDialogOpen}
onClose={handleClose}
PaperProps={{
sx: {
width: "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: `
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: `
0px 100px 309px rgba(210, 208, 225, 0.24),
0px 41.7776px 129.093px rgba(210, 208, 225, 0.172525),
0px 22.3363px 69.0192px rgba(210, 208, 225, 0.143066),

@ -48,8 +48,8 @@ export default function SignupDialog() {
makeRequest<RegisterRequest, RegisterResponse>({
url: "https://hub.pena.digital/auth/register",
body: {
login: values.login,
password: values.password,
login: values.login.trim(),
password: values.password.trim(),
phoneNumber: "-",
},
useToken: false,
@ -82,6 +82,7 @@ export default function SignupDialog() {
PaperProps={{
sx: {
width: "600px",
maxWidth: "600px",
}
}}
slotProps={{

@ -14,9 +14,11 @@ interface FirstRequest<T> {
method?: string;
url: string;
body?: T;
/** Send access token */
useToken?: boolean;
contentType?: boolean;
signal?: AbortSignal;
/** Send refresh token */
withCredentials?: boolean;
}

@ -1,16 +1,57 @@
import { User } from "@root/model/auth";
import { PatchUserRequest, UserDocumentTypes, UserDocuments, UserSettings, UserSettingsField, UserWithFields, VerificationStatus } from "@root/model/user";
import { produce } from "immer";
import { create } from "zustand";
import { createJSONStorage, devtools, persist } from "zustand/middleware";
import { StringSchema, string } from "yup";
import { patchUser } from "@root/api/user";
interface UserStore {
userId: string | null;
user: User | null;
user: UserWithFields | null;
settingsFields: UserSettings | null;
verificationStatus: VerificationStatus;
verificationType: "juridical" | "nko";
isDocumentsDialogOpen: boolean;
dialogType: "juridical" | "nko";
documents: UserDocuments;
}
const defaultFieldValues = {
value: "",
error: null,
touched: false,
};
const defaultFields = {
name: { ...defaultFieldValues },
surname: { ...defaultFieldValues },
middleName: { ...defaultFieldValues },
companyName: { ...defaultFieldValues },
email: { ...defaultFieldValues },
phoneNumber: { ...defaultFieldValues },
password: { ...defaultFieldValues },
};
const defaultDocument = {
file: null,
uploadedFileName: null,
imageSrc: null,
};
const initialState: UserStore = {
userId: null,
user: null,
settingsFields: { ...defaultFields },
verificationStatus: "notVerificated",
verificationType: "juridical",
isDocumentsDialogOpen: false,
dialogType: "juridical",
documents: {
"ИНН": { ...defaultDocument },
"Устав": { ...defaultDocument },
"Свидетельство о регистрации НКО": { ...defaultDocument },
}
};
export const useUserStore = create<UserStore>()(
@ -19,16 +60,159 @@ export const useUserStore = create<UserStore>()(
(set, get) => initialState,
{
name: "User store",
enabled: process.env.NODE_ENV === "development",
}
),
{
version: 1,
name: "user",
storage: createJSONStorage(() => localStorage),
partialize: state => ({
userId: state.userId,
user: state.user,
})
}
)
);
export const setUserId = (userId: string | null) => useUserStore.setState({ userId });
export const setUser = (user: User | null) => useUserStore.setState({ user });
export const clearUser = () => useUserStore.setState({ ...initialState });
export const openDocumentsDialog = (type: UserStore["dialogType"]) => useUserStore.setState(
produce<UserStore>(state => {
state.isDocumentsDialogOpen = true;
state.dialogType = type;
})
);
export const closeDocumentsDialog = () => useUserStore.setState(
produce<UserStore>(state => {
state.isDocumentsDialogOpen = false;
})
);
export const setDocument = (type: UserDocumentTypes, file: File | undefined) => useUserStore.setState(
produce<UserStore>(state => {
if (!file) {
state.documents[type] = { ...defaultDocument };
return;
}
let imageSrc: string | null = null;
try {
const src = state.documents[type].imageSrc;
if (src) URL.revokeObjectURL(src);
imageSrc = URL.createObjectURL(file);
} catch (error) {
console.log(error);
}
state.documents[type] = {
file,
uploadedFileName: null,
imageSrc,
};
})
);
export const setUploadedDocument = (type: UserDocumentTypes, fileName: string, url: string) => useUserStore.setState(
produce<UserStore>(state => {
state.documents[type] = {
file: null,
uploadedFileName: fileName,
imageSrc: url,
};
})
);
export const sendDocuments = () => {
const state = useUserStore.getState();
const type = state.dialogType;
const documents = state.documents;
// const formData = new FormData();
// formData.append("file1", file1);
// formData.append("file2", file2);
// revoke on success
// Object.values(documents).map(document => document?.imageSrc).forEach(src => {
// if (src) URL.revokeObjectURL(src);
// });
// useUserStore.setState(produce<UserStore>(state => {
// state.isDocumentsDialogOpen = false;
// }));
};
export const setUser = (user: UserWithFields | null) => useUserStore.setState(
produce<UserStore>(state => {
state.user = user;
if (!user) {
state.settingsFields = null;
return;
}
if (!state.settingsFields) state.settingsFields = { ...defaultFields };
state.settingsFields.name.value = user.name || "";
state.settingsFields.surname.value = user.surname || "";
state.settingsFields.middleName.value = user.middleName || "";
state.settingsFields.companyName.value = user.companyName || "";
state.settingsFields.email.value = user.email;
state.settingsFields.phoneNumber.value = user.phoneNumber;
state.settingsFields.password.value = "";
})
);
export const clearUser = () => useUserStore.setState({ ...initialState });
export const setSettingsField = (
fieldName: UserSettingsField,
value: string,
) => useUserStore.setState(
produce<UserStore>(state => {
if (!state.settingsFields) return;
let errorMessage: string | null = null;
try {
validators[fieldName].validateSync(value);
} catch (error: any) {
errorMessage = error.message;
}
state.settingsFields[fieldName].value = value || "";
state.settingsFields[fieldName].touched = true;
state.settingsFields[fieldName].error = errorMessage;
})
);
export const sendUserData = async () => {
const state = useUserStore.getState();
if (!state.settingsFields) return;
const payload: PatchUserRequest = {};
for (const [fieldName, fieldValue] of Object.entries(state.settingsFields)) {
if (
fieldValue.value !== (state.user?.[fieldName as UserSettingsField] ?? "")
) payload[fieldName as UserSettingsField] = fieldValue.value;
}
const user = await patchUser(payload);
setUser(user);
};
const validators: Record<UserSettingsField, StringSchema> = {
name: string(),
email: string().email("Неверный email"),
surname: string(),
phoneNumber: string().matches(/^[+\d|\d]*$/, "Неверный номер телефона").min(6, "Номер телефона должен содержать минимум 6 символов"),
middleName: string(),
password: string().min(8, "Минимум 8 символов").matches(/^[.,:;-_+\d\w]+$/, "Некорректные символы в пароле"),
companyName: string(),
};