base commit
This commit is contained in:
@@ -0,0 +1,318 @@
|
||||
import { useState } from 'react'
|
||||
import Alert from '@mui/material/Alert'
|
||||
import Box from '@mui/material/Box'
|
||||
import Button from '@mui/material/Button'
|
||||
import Chip from '@mui/material/Chip'
|
||||
import Dialog from '@mui/material/Dialog'
|
||||
import DialogActions from '@mui/material/DialogActions'
|
||||
import DialogContent from '@mui/material/DialogContent'
|
||||
import DialogTitle from '@mui/material/DialogTitle'
|
||||
import FormControlLabel from '@mui/material/FormControlLabel'
|
||||
import Stack from '@mui/material/Stack'
|
||||
import Switch from '@mui/material/Switch'
|
||||
import TextField from '@mui/material/TextField'
|
||||
import Typography from '@mui/material/Typography'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { Controller, useForm } from 'react-hook-form'
|
||||
import {
|
||||
createMyAddress,
|
||||
deleteMyAddress,
|
||||
fetchMyAddresses,
|
||||
setMyAddressDefault,
|
||||
updateMyAddress,
|
||||
} from '@/entities/user/api/address-api'
|
||||
import type { ShippingAddress } from '@/entities/user/model/types'
|
||||
import { AddressMapPicker } from '@/features/address-map-picker/ui/AddressMapPicker'
|
||||
|
||||
export function AddressesPage() {
|
||||
const queryClient = useQueryClient()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [editing, setEditing] = useState<ShippingAddress | null>(null)
|
||||
|
||||
const addressesQuery = useQuery({
|
||||
queryKey: ['me', 'addresses'],
|
||||
queryFn: fetchMyAddresses,
|
||||
})
|
||||
|
||||
const form = useForm<{
|
||||
label: string
|
||||
recipientName: string
|
||||
recipientPhone: string
|
||||
addressLine: string
|
||||
comment: string
|
||||
lat: number | null
|
||||
lng: number | null
|
||||
isDefault: boolean
|
||||
}>({
|
||||
defaultValues: {
|
||||
label: '',
|
||||
recipientName: '',
|
||||
recipientPhone: '',
|
||||
addressLine: '',
|
||||
comment: '',
|
||||
lat: null,
|
||||
lng: null,
|
||||
isDefault: false,
|
||||
},
|
||||
mode: 'onChange',
|
||||
})
|
||||
|
||||
const createMut = useMutation({
|
||||
mutationFn: async () => {
|
||||
const v = form.getValues()
|
||||
if (v.lat === null || v.lng === null) throw new Error('Выберите точку на карте')
|
||||
await createMyAddress({
|
||||
label: v.label.trim() || null,
|
||||
recipientName: v.recipientName.trim(),
|
||||
recipientPhone: v.recipientPhone.trim(),
|
||||
addressLine: v.addressLine.trim(),
|
||||
comment: v.comment.trim() || null,
|
||||
lat: v.lat,
|
||||
lng: v.lng,
|
||||
isDefault: v.isDefault,
|
||||
})
|
||||
},
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ['me', 'addresses'] })
|
||||
setOpen(false)
|
||||
},
|
||||
})
|
||||
|
||||
const updateMut = useMutation({
|
||||
mutationFn: async () => {
|
||||
const v = form.getValues()
|
||||
if (v.lat === null || v.lng === null) throw new Error('Выберите точку на карте')
|
||||
await updateMyAddress(editing!.id, {
|
||||
label: v.label.trim() || null,
|
||||
recipientName: v.recipientName.trim(),
|
||||
recipientPhone: v.recipientPhone.trim(),
|
||||
addressLine: v.addressLine.trim(),
|
||||
comment: v.comment.trim() || null,
|
||||
lat: v.lat,
|
||||
lng: v.lng,
|
||||
isDefault: v.isDefault,
|
||||
})
|
||||
},
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ['me', 'addresses'] })
|
||||
setOpen(false)
|
||||
},
|
||||
})
|
||||
|
||||
const deleteMut = useMutation({
|
||||
mutationFn: (id: string) => deleteMyAddress(id),
|
||||
onSuccess: () => void queryClient.invalidateQueries({ queryKey: ['me', 'addresses'] }),
|
||||
})
|
||||
|
||||
const defaultMut = useMutation({
|
||||
mutationFn: (id: string) => setMyAddressDefault(id),
|
||||
onSuccess: () => void queryClient.invalidateQueries({ queryKey: ['me', 'addresses'] }),
|
||||
})
|
||||
|
||||
const error = createMut.error ?? updateMut.error ?? deleteMut.error ?? defaultMut.error
|
||||
|
||||
const items = addressesQuery.data?.items ?? []
|
||||
|
||||
const openCreate = () => {
|
||||
setEditing(null)
|
||||
form.reset({
|
||||
label: '',
|
||||
recipientName: '',
|
||||
recipientPhone: '',
|
||||
addressLine: '',
|
||||
comment: '',
|
||||
lat: null,
|
||||
lng: null,
|
||||
isDefault: items.length === 0,
|
||||
})
|
||||
setOpen(true)
|
||||
}
|
||||
|
||||
const openEdit = (a: ShippingAddress) => {
|
||||
setEditing(a)
|
||||
form.reset({
|
||||
label: a.label ?? '',
|
||||
recipientName: a.recipientName,
|
||||
recipientPhone: a.recipientPhone,
|
||||
addressLine: a.addressLine,
|
||||
comment: a.comment ?? '',
|
||||
lat: a.lat,
|
||||
lng: a.lng,
|
||||
isDefault: a.isDefault,
|
||||
})
|
||||
setOpen(true)
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="h4" gutterBottom>
|
||||
Адреса доставки
|
||||
</Typography>
|
||||
|
||||
<Typography color="text.secondary" sx={{ mb: 2 }}>
|
||||
Можно добавить несколько адресов. Для каждого адреса задаются ФИО и телефон получателя отдельно.
|
||||
</Typography>
|
||||
|
||||
<Stack direction="row" spacing={2} sx={{ mb: 2 }}>
|
||||
<Button variant="contained" onClick={openCreate}>
|
||||
Добавить адрес
|
||||
</Button>
|
||||
</Stack>
|
||||
|
||||
{addressesQuery.isError && (
|
||||
<Alert severity="error" sx={{ mb: 2 }}>
|
||||
Не удалось загрузить адреса. Проверьте, что вы вошли в аккаунт и сервер запущен.
|
||||
</Alert>
|
||||
)}
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mb: 2 }}>
|
||||
{(error as Error).message}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Stack spacing={2}>
|
||||
{items.map((a) => (
|
||||
<Box
|
||||
key={a.id}
|
||||
sx={{
|
||||
border: 1,
|
||||
borderColor: 'divider',
|
||||
borderRadius: 2,
|
||||
p: 2,
|
||||
bgcolor: 'background.paper',
|
||||
}}
|
||||
>
|
||||
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={1} alignItems={{ sm: 'center' }}>
|
||||
<Typography sx={{ fontWeight: 700, flexGrow: 1 }}>{a.label?.trim() ? a.label : 'Адрес'}</Typography>
|
||||
{a.isDefault && <Chip label="По умолчанию" color="primary" size="small" />}
|
||||
</Stack>
|
||||
|
||||
<Typography sx={{ mt: 1 }}>{a.addressLine}</Typography>
|
||||
<Typography color="text.secondary" variant="body2" sx={{ mt: 0.5 }}>
|
||||
Получатель: {a.recipientName} · {a.recipientPhone}
|
||||
</Typography>
|
||||
{a.comment && (
|
||||
<Typography color="text.secondary" variant="body2" sx={{ mt: 0.5 }}>
|
||||
Комментарий: {a.comment}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
<Stack direction="row" spacing={1} sx={{ mt: 1.5, flexWrap: 'wrap' }}>
|
||||
{!a.isDefault && (
|
||||
<Button size="small" onClick={() => defaultMut.mutate(a.id)} disabled={defaultMut.isPending}>
|
||||
Сделать основным
|
||||
</Button>
|
||||
)}
|
||||
<Button size="small" onClick={() => openEdit(a)}>
|
||||
Изменить
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
color="error"
|
||||
disabled={deleteMut.isPending}
|
||||
onClick={() => {
|
||||
if (!confirm('Удалить адрес?')) return
|
||||
deleteMut.mutate(a.id)
|
||||
}}
|
||||
>
|
||||
Удалить
|
||||
</Button>
|
||||
</Stack>
|
||||
</Box>
|
||||
))}
|
||||
|
||||
{addressesQuery.isSuccess && items.length === 0 && (
|
||||
<Alert severity="info">Адресов пока нет — добавьте первый адрес доставки.</Alert>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
<Dialog open={open} onClose={() => setOpen(false)} fullWidth maxWidth="md">
|
||||
<DialogTitle>{editing ? 'Редактировать адрес' : 'Новый адрес'}</DialogTitle>
|
||||
<DialogContent>
|
||||
<Stack spacing={2} sx={{ mt: 1 }}>
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="label"
|
||||
render={({ field }) => <TextField label="Метка (например: Дом/Работа)" fullWidth {...field} />}
|
||||
/>
|
||||
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2}>
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="recipientName"
|
||||
render={({ field }) => <TextField label="ФИО получателя" fullWidth required {...field} />}
|
||||
/>
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="recipientPhone"
|
||||
render={({ field }) => <TextField label="Телефон получателя" fullWidth required {...field} />}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="addressLine"
|
||||
render={({ field }) => <TextField label="Адрес" fullWidth required {...field} />}
|
||||
/>
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="comment"
|
||||
render={({ field }) => <TextField label="Комментарий (необязательно)" fullWidth {...field} />}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="lat"
|
||||
render={({ field: latField }) => (
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="lng"
|
||||
render={({ field: lngField }) => (
|
||||
<AddressMapPicker
|
||||
value={
|
||||
latField.value !== null && lngField.value !== null
|
||||
? { lat: latField.value, lng: lngField.value }
|
||||
: null
|
||||
}
|
||||
onChange={(v) => {
|
||||
latField.onChange(v.lat)
|
||||
lngField.onChange(v.lng)
|
||||
if (v.addressLine) form.setValue('addressLine', v.addressLine, { shouldDirty: true })
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="isDefault"
|
||||
render={({ field }) => (
|
||||
<FormControlLabel
|
||||
control={<Switch checked={Boolean(field.value)} onChange={(_, v) => field.onChange(v)} />}
|
||||
label="Адрес по умолчанию"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Stack>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setOpen(false)}>Отмена</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={() => (editing ? updateMut.mutate() : createMut.mutate())}
|
||||
disabled={
|
||||
createMut.isPending ||
|
||||
updateMut.isPending ||
|
||||
!form.watch('recipientName').trim() ||
|
||||
!form.watch('recipientPhone').trim() ||
|
||||
!form.watch('addressLine').trim()
|
||||
}
|
||||
>
|
||||
Сохранить
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import Box from '@mui/material/Box'
|
||||
import Typography from '@mui/material/Typography'
|
||||
|
||||
export function MessagesPage() {
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="h4" gutterBottom>
|
||||
Сообщения
|
||||
</Typography>
|
||||
<Typography color="text.secondary">Скоро здесь появятся сообщения и уведомления.</Typography>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import Box from '@mui/material/Box'
|
||||
import Typography from '@mui/material/Typography'
|
||||
|
||||
export function OrdersPage() {
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="h4" gutterBottom>
|
||||
Заказы
|
||||
</Typography>
|
||||
<Typography color="text.secondary">Скоро здесь появится история заказов.</Typography>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
import Alert from '@mui/material/Alert'
|
||||
import Box from '@mui/material/Box'
|
||||
import Button from '@mui/material/Button'
|
||||
import Divider from '@mui/material/Divider'
|
||||
import Stack from '@mui/material/Stack'
|
||||
import TextField from '@mui/material/TextField'
|
||||
import Typography from '@mui/material/Typography'
|
||||
import { useUnit } from 'effector-react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import {
|
||||
$changePasswordError,
|
||||
$requestEmailChangeCodeError,
|
||||
$updateProfileError,
|
||||
$user,
|
||||
$verifyEmailChangeError,
|
||||
changePasswordFx,
|
||||
requestEmailChangeCodeFx,
|
||||
updateProfileFx,
|
||||
verifyEmailChangeFx,
|
||||
} from '@/shared/model/auth'
|
||||
import type { AxiosError } from 'axios'
|
||||
|
||||
function getApiErrorMessage(error: unknown): string | null {
|
||||
const e = error as AxiosError<{ error?: string }>
|
||||
const msg = e?.response?.data?.error
|
||||
return msg ? String(msg) : null
|
||||
}
|
||||
|
||||
export function SettingsPage() {
|
||||
const user = useUnit($user)
|
||||
const pendingPassword = useUnit(changePasswordFx.pending)
|
||||
const pendingEmailReq = useUnit(requestEmailChangeCodeFx.pending)
|
||||
const pendingEmailVerify = useUnit(verifyEmailChangeFx.pending)
|
||||
const pendingProfile = useUnit(updateProfileFx.pending)
|
||||
const errorPassword = useUnit($changePasswordError)
|
||||
const errorEmailReq = useUnit($requestEmailChangeCodeError)
|
||||
const errorProfile = useUnit($updateProfileError)
|
||||
const errorEmailVerify = useUnit($verifyEmailChangeError)
|
||||
|
||||
const passwordForm = useForm<{ currentPassword: string; newPassword: string }>({
|
||||
defaultValues: { currentPassword: '', newPassword: '' },
|
||||
mode: 'onChange',
|
||||
})
|
||||
|
||||
const emailForm = useForm<{ newEmail: string; code: string }>({
|
||||
defaultValues: { newEmail: '', code: '' },
|
||||
mode: 'onChange',
|
||||
})
|
||||
|
||||
const profileForm = useForm<{ name: string; phone: string }>({
|
||||
defaultValues: { name: user?.name ? String(user.name) : '', phone: user?.phone ? String(user.phone) : '' },
|
||||
mode: 'onChange',
|
||||
})
|
||||
|
||||
const passwordErrorMsg = getApiErrorMessage(errorPassword)
|
||||
const emailErrorMsg = getApiErrorMessage(errorEmailReq) ?? getApiErrorMessage(errorEmailVerify)
|
||||
const profileErrorMsg = getApiErrorMessage(errorProfile)
|
||||
|
||||
if (!user) {
|
||||
return <Alert severity="info">Нужно войти. Перейдите на страницу «Вход».</Alert>
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="h4" gutterBottom>
|
||||
Настройки
|
||||
</Typography>
|
||||
<Typography color="text.secondary" sx={{ mb: 3 }}>
|
||||
Текущая почта: <b>{user.email}</b>
|
||||
</Typography>
|
||||
|
||||
{passwordErrorMsg && (
|
||||
<Alert severity="error" sx={{ mb: 2 }}>
|
||||
{passwordErrorMsg}
|
||||
</Alert>
|
||||
)}
|
||||
{emailErrorMsg && (
|
||||
<Alert severity="error" sx={{ mb: 2 }}>
|
||||
{emailErrorMsg}
|
||||
</Alert>
|
||||
)}
|
||||
{profileErrorMsg && (
|
||||
<Alert severity="error" sx={{ mb: 2 }}>
|
||||
{profileErrorMsg}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Stack spacing={3} sx={{ maxWidth: 560 }}>
|
||||
<Box>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Профиль
|
||||
</Typography>
|
||||
<Stack spacing={2}>
|
||||
<TextField
|
||||
label="Имя или ник"
|
||||
helperText="До 40 символов"
|
||||
slotProps={{ htmlInput: { maxLength: 40 } }}
|
||||
{...profileForm.register('name')}
|
||||
/>
|
||||
<TextField
|
||||
label="Телефон"
|
||||
helperText="Можно указать для связи по заказам"
|
||||
{...profileForm.register('phone')}
|
||||
/>
|
||||
<Button
|
||||
variant="contained"
|
||||
disabled={pendingProfile}
|
||||
onClick={() => {
|
||||
const raw = profileForm.getValues('name')
|
||||
const name = raw.trim()
|
||||
const phoneRaw = profileForm.getValues('phone')
|
||||
const phone = phoneRaw.trim()
|
||||
updateProfileFx({ name: name.length ? name : null, phone: phone.length ? phone : null })
|
||||
}}
|
||||
>
|
||||
Сохранить
|
||||
</Button>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Box>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Смена почты
|
||||
</Typography>
|
||||
<Stack spacing={2}>
|
||||
<TextField label="Новая почта" {...emailForm.register('newEmail')} />
|
||||
<Button
|
||||
variant="outlined"
|
||||
disabled={!emailForm.watch('newEmail') || pendingEmailReq}
|
||||
onClick={() => requestEmailChangeCodeFx(emailForm.getValues('newEmail').trim())}
|
||||
>
|
||||
Отправить код на новую почту
|
||||
</Button>
|
||||
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2}>
|
||||
<TextField label="Код (6 цифр)" inputMode="numeric" {...emailForm.register('code')} />
|
||||
<Button
|
||||
variant="contained"
|
||||
disabled={emailForm.watch('code').trim().length !== 6 || pendingEmailVerify}
|
||||
onClick={() =>
|
||||
verifyEmailChangeFx({
|
||||
newEmail: emailForm.getValues('newEmail').trim(),
|
||||
code: emailForm.getValues('code').trim(),
|
||||
})
|
||||
}
|
||||
>
|
||||
Подтвердить
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Box>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Смена пароля
|
||||
</Typography>
|
||||
<Stack spacing={2}>
|
||||
<TextField
|
||||
label="Текущий пароль (если установлен)"
|
||||
type="password"
|
||||
{...passwordForm.register('currentPassword')}
|
||||
/>
|
||||
<TextField label="Новый пароль" type="password" {...passwordForm.register('newPassword')} />
|
||||
<Button
|
||||
variant="contained"
|
||||
disabled={passwordForm.watch('newPassword').length < 8 || pendingPassword}
|
||||
onClick={() =>
|
||||
changePasswordFx({
|
||||
currentPassword: passwordForm.getValues('currentPassword') || undefined,
|
||||
newPassword: passwordForm.getValues('newPassword'),
|
||||
})
|
||||
}
|
||||
>
|
||||
Сохранить пароль
|
||||
</Button>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user