new features
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
export interface IAvatarUploadProps {
|
||||
currentAvatarUrl: string | null
|
||||
onAvatarChange: (url: string) => void
|
||||
className?: string
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
.root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.avatarButton {
|
||||
all: unset;
|
||||
cursor: pointer;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.changeLink {
|
||||
all: unset;
|
||||
cursor: pointer;
|
||||
color: var(--accent-9);
|
||||
font-size: 14px;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.fileInput {
|
||||
display: none;
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
"use client"
|
||||
|
||||
import type { IAvatarUploadProps } from "./AvatarUpload.d"
|
||||
import type { JSX } from "react"
|
||||
|
||||
import cs from "classnames"
|
||||
import { FunctionComponent, useRef, useState } from "react"
|
||||
|
||||
import { uploadFileWithProgress } from "@shared/api/uploadFile"
|
||||
import { Avatar } from "@shared/ui/Avatar"
|
||||
|
||||
import styles from "./AvatarUpload.module.scss"
|
||||
|
||||
export const AvatarUpload: FunctionComponent<
|
||||
IAvatarUploadProps
|
||||
> = ({ currentAvatarUrl, onAvatarChange, className }): JSX.Element => {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const [uploadProgress, setUploadProgress] = useState<number | null>(null)
|
||||
|
||||
const handleClick = () => {
|
||||
fileInputRef.current?.click()
|
||||
}
|
||||
|
||||
const handleFileChange = async (
|
||||
e: React.ChangeEvent<HTMLInputElement>,
|
||||
) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
|
||||
setUploadProgress(0)
|
||||
try {
|
||||
const result = await uploadFileWithProgress(
|
||||
file,
|
||||
"avatars",
|
||||
setUploadProgress,
|
||||
)
|
||||
onAvatarChange(result.file_path)
|
||||
} catch (err) {
|
||||
console.error("Avatar upload failed:", err)
|
||||
} finally {
|
||||
setUploadProgress(null)
|
||||
if (fileInputRef.current) fileInputRef.current.value = ""
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cs(styles.root, className)}
|
||||
data-testid="AvatarUpload"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.avatarButton}
|
||||
onClick={handleClick}
|
||||
disabled={uploadProgress !== null}
|
||||
>
|
||||
<Avatar
|
||||
size="xlarge"
|
||||
url={currentAvatarUrl || ""}
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.changeLink}
|
||||
onClick={handleClick}
|
||||
disabled={uploadProgress !== null}
|
||||
>
|
||||
{uploadProgress !== null
|
||||
? `Загрузка ${uploadProgress}%`
|
||||
: "Изменить фото"}
|
||||
</button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className={styles.fileInput}
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./AvatarUpload"
|
||||
@@ -0,0 +1,3 @@
|
||||
export interface IChangePasswordFormProps {
|
||||
className?: string
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
.title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.fields {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
"use client"
|
||||
|
||||
import type { IChangePasswordFormProps } from "./ChangePasswordForm.d"
|
||||
import type { JSX } from "react"
|
||||
|
||||
import { FunctionComponent, useState } from "react"
|
||||
import { useForm } from "react-hook-form"
|
||||
|
||||
import api from "@shared/api"
|
||||
import { Alert, Button, Form, TextField } from "@shared/ui"
|
||||
|
||||
import styles from "./ChangePasswordForm.module.scss"
|
||||
|
||||
interface IPasswordFormData {
|
||||
current_password: string
|
||||
new_password: string
|
||||
confirm_password: string
|
||||
}
|
||||
|
||||
export const ChangePasswordForm: FunctionComponent<
|
||||
IChangePasswordFormProps
|
||||
> = ({ className }): JSX.Element => {
|
||||
const [successMessage, setSuccessMessage] = useState(false)
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null)
|
||||
|
||||
const { register, handleSubmit, reset, formState: { errors } } = useForm<IPasswordFormData>({
|
||||
defaultValues: {
|
||||
current_password: "",
|
||||
new_password: "",
|
||||
confirm_password: "",
|
||||
},
|
||||
})
|
||||
|
||||
const { mutate, isPending } = api.useMutation(
|
||||
"post",
|
||||
"/api/users/me/change-password/",
|
||||
{
|
||||
onSuccess: () => {
|
||||
setErrorMessage(null)
|
||||
setSuccessMessage(true)
|
||||
reset()
|
||||
setTimeout(() => setSuccessMessage(false), 3000)
|
||||
},
|
||||
onError: (error) => {
|
||||
setSuccessMessage(false)
|
||||
setErrorMessage(
|
||||
(error as { detail?: string })?.detail || "Не удалось сменить пароль",
|
||||
)
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
const onSubmit = (data: IPasswordFormData) => {
|
||||
setErrorMessage(null)
|
||||
mutate({
|
||||
body: {
|
||||
current_password: data.current_password,
|
||||
new_password: data.new_password,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={className} data-testid="ChangePasswordForm">
|
||||
<h3 className={styles.title}>Смена пароля</h3>
|
||||
<Form className={styles.form} onSubmit={handleSubmit(onSubmit)}>
|
||||
<div className={styles.fields}>
|
||||
<TextField
|
||||
id="current_password"
|
||||
label="Текущий пароль"
|
||||
placeholder="Введите текущий пароль"
|
||||
type="password"
|
||||
{...register("current_password", { required: "Обязательное поле" })}
|
||||
/>
|
||||
<TextField
|
||||
id="new_password"
|
||||
label="Новый пароль"
|
||||
placeholder="Введите новый пароль"
|
||||
type="password"
|
||||
{...register("new_password", { required: "Обязательное поле" })}
|
||||
/>
|
||||
<TextField
|
||||
id="confirm_password"
|
||||
label="Подтверждение пароля"
|
||||
placeholder="Повторите новый пароль"
|
||||
type="password"
|
||||
error={!!errors.confirm_password}
|
||||
{...register("confirm_password", {
|
||||
required: "Обязательное поле",
|
||||
validate: (value, formValues) =>
|
||||
value === formValues.new_password || "Пароли не совпадают",
|
||||
})}
|
||||
/>
|
||||
{errors.confirm_password && (
|
||||
<Alert variant="danger">{errors.confirm_password.message}</Alert>
|
||||
)}
|
||||
</div>
|
||||
{successMessage && (
|
||||
<Alert variant="success">Пароль успешно изменён</Alert>
|
||||
)}
|
||||
{errorMessage && <Alert variant="danger">{errorMessage}</Alert>}
|
||||
<Button type="submit" variant="primary" disabled={isPending}>
|
||||
{isPending ? "Сохранение..." : "Сменить пароль"}
|
||||
</Button>
|
||||
</Form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./ChangePasswordForm"
|
||||
@@ -0,0 +1,6 @@
|
||||
import type { components } from "@shared/api/__generated__/openapi.types"
|
||||
|
||||
export interface IEditProfileFormProps {
|
||||
user: components["schemas"]["UserRead"]
|
||||
className?: string
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
.title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.fields {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
"use client"
|
||||
|
||||
import type { IEditProfileFormProps } from "./EditProfileForm.d"
|
||||
import type { JSX } from "react"
|
||||
|
||||
import { FunctionComponent, useState } from "react"
|
||||
import { useForm } from "react-hook-form"
|
||||
|
||||
import api from "@shared/api"
|
||||
import { useAppDispatch } from "@shared/hooks/useAppDispatch"
|
||||
import { setUser } from "@shared/store/user"
|
||||
import { Alert, Button, Form, TextField } from "@shared/ui"
|
||||
|
||||
import styles from "./EditProfileForm.module.scss"
|
||||
|
||||
interface IProfileFormData {
|
||||
first_name: string
|
||||
last_name: string
|
||||
email: string
|
||||
phone_number: string
|
||||
}
|
||||
|
||||
export const EditProfileForm: FunctionComponent<
|
||||
IEditProfileFormProps
|
||||
> = ({ user, className }): JSX.Element => {
|
||||
const dispatch = useAppDispatch()
|
||||
const [successMessage, setSuccessMessage] = useState(false)
|
||||
|
||||
const { register, handleSubmit } = useForm<IProfileFormData>({
|
||||
defaultValues: {
|
||||
first_name: user.first_name || "",
|
||||
last_name: user.last_name || "",
|
||||
email: user.email || "",
|
||||
phone_number: user.phone_number || "",
|
||||
},
|
||||
})
|
||||
|
||||
const { mutate, isPending } = api.useMutation(
|
||||
"patch",
|
||||
"/api/users/{user_id}/",
|
||||
{
|
||||
onSuccess: (data) => {
|
||||
dispatch(setUser(data))
|
||||
setSuccessMessage(true)
|
||||
setTimeout(() => setSuccessMessage(false), 3000)
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
const onSubmit = (data: IProfileFormData) => {
|
||||
mutate({
|
||||
params: { path: { user_id: user.id } },
|
||||
body: {
|
||||
first_name: data.first_name || null,
|
||||
last_name: data.last_name || null,
|
||||
email: data.email || null,
|
||||
phone_number: data.phone_number || null,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={className} data-testid="EditProfileForm">
|
||||
<h3 className={styles.title}>Личная информация</h3>
|
||||
<Form className={styles.form} onSubmit={handleSubmit(onSubmit)}>
|
||||
<div className={styles.fields}>
|
||||
<TextField
|
||||
id="first_name"
|
||||
label="Имя"
|
||||
placeholder="Ваше имя"
|
||||
{...register("first_name")}
|
||||
/>
|
||||
<TextField
|
||||
id="last_name"
|
||||
label="Фамилия"
|
||||
placeholder="Ваша фамилия"
|
||||
{...register("last_name")}
|
||||
/>
|
||||
<TextField
|
||||
id="email"
|
||||
label="Email"
|
||||
placeholder="Ваш email"
|
||||
type="email"
|
||||
{...register("email")}
|
||||
/>
|
||||
<TextField
|
||||
id="phone_number"
|
||||
label="Телефон"
|
||||
placeholder="Ваш телефон"
|
||||
{...register("phone_number")}
|
||||
/>
|
||||
</div>
|
||||
{successMessage && (
|
||||
<Alert variant="success">Данные успешно сохранены</Alert>
|
||||
)}
|
||||
<Button type="submit" variant="primary" disabled={isPending}>
|
||||
{isPending ? "Сохранение..." : "Сохранить"}
|
||||
</Button>
|
||||
</Form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./EditProfileForm"
|
||||
@@ -0,0 +1,3 @@
|
||||
export interface ILogoutButtonProps {
|
||||
className?: string
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
.root {
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
"use client"
|
||||
|
||||
import type { ILogoutButtonProps } from "./LogoutButton.d"
|
||||
import type { JSX } from "react"
|
||||
|
||||
import Cookies from "js-cookie"
|
||||
import { FunctionComponent } from "react"
|
||||
|
||||
import { useRouter } from "next/navigation"
|
||||
|
||||
import { useAppDispatch } from "@shared/hooks/useAppDispatch"
|
||||
import {
|
||||
ACCESS_TOKEN_COOKIE,
|
||||
REFRESH_TOKEN_COOKIE,
|
||||
} from "@shared/lib/constants"
|
||||
import { resetUser } from "@shared/store/user"
|
||||
import { Button } from "@shared/ui"
|
||||
|
||||
export const LogoutButton: FunctionComponent<
|
||||
ILogoutButtonProps
|
||||
> = ({ className }): JSX.Element => {
|
||||
const router = useRouter()
|
||||
const dispatch = useAppDispatch()
|
||||
|
||||
const handleLogout = () => {
|
||||
Cookies.remove(ACCESS_TOKEN_COOKIE)
|
||||
Cookies.remove(REFRESH_TOKEN_COOKIE)
|
||||
dispatch(resetUser())
|
||||
router.push("/login")
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="danger"
|
||||
size="lg"
|
||||
className={className}
|
||||
onClick={handleLogout}
|
||||
data-testid="LogoutButton"
|
||||
>
|
||||
Выйти из аккаунта
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./LogoutButton"
|
||||
@@ -0,0 +1,4 @@
|
||||
export { AvatarUpload } from "./AvatarUpload"
|
||||
export { ChangePasswordForm } from "./ChangePasswordForm"
|
||||
export { EditProfileForm } from "./EditProfileForm"
|
||||
export { LogoutButton } from "./LogoutButton"
|
||||
Reference in New Issue
Block a user