base commit

This commit is contained in:
@kirill.komarov
2026-04-29 18:34:25 +05:00
parent f6b6959268
commit 326521c9e6
20 changed files with 1825 additions and 36 deletions
+2 -2
View File
@@ -5,7 +5,7 @@ import { AdminPage } from '@/pages/admin'
import { AdminUsersPage } from '@/pages/admin-users'
import { AuthPage } from '@/pages/auth'
import { HomePage } from '@/pages/home'
import { MePage } from '@/pages/me/ui/MePage'
import { MeLayoutPage } from '@/pages/me'
import { ProductPage } from '@/pages/product'
export function App() {
@@ -18,7 +18,7 @@ export function App() {
<Route path="/admin" element={<AdminPage />} />
<Route path="/admin/users" element={<AdminUsersPage />} />
<Route path="/auth" element={<AuthPage />} />
<Route path="/me" element={<MePage />} />
<Route path="/me/*" element={<MeLayoutPage />} />
<Route path="/products/:id" element={<ProductPage />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
@@ -0,0 +1,49 @@
import type { ShippingAddress } from '@/entities/user/model/types'
import { apiClient } from '@/shared/api/client'
export type AddressesListResponse = { items: ShippingAddress[] }
export async function fetchMyAddresses(): Promise<AddressesListResponse> {
const { data } = await apiClient.get<AddressesListResponse>('me/addresses')
return data
}
export async function createMyAddress(body: {
label?: string | null
recipientName: string
recipientPhone: string
addressLine: string
comment?: string | null
lat: number
lng: number
isDefault?: boolean
}): Promise<{ item: ShippingAddress }> {
const { data } = await apiClient.post<{ item: ShippingAddress }>('me/addresses', body)
return data
}
export async function updateMyAddress(
id: string,
body: Partial<{
label: string | null
recipientName: string
recipientPhone: string
addressLine: string
comment: string | null
lat: number
lng: number
isDefault: boolean
}>,
): Promise<{ item: ShippingAddress }> {
const { data } = await apiClient.patch<{ item: ShippingAddress }>(`me/addresses/${id}`, body)
return data
}
export async function deleteMyAddress(id: string): Promise<void> {
await apiClient.delete(`me/addresses/${id}`)
}
export async function setMyAddressDefault(id: string): Promise<{ item: ShippingAddress }> {
const { data } = await apiClient.post<{ item: ShippingAddress }>(`me/addresses/${id}/default`)
return data
}
+14
View File
@@ -6,3 +6,17 @@ export type AdminUser = {
createdAt: string
updatedAt: string
}
export type ShippingAddress = {
id: string
label: string | null
recipientName: string
recipientPhone: string
addressLine: string
comment: string | null
lat: number
lng: number
isDefault: boolean
createdAt: string
updatedAt: string
}
@@ -0,0 +1,231 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import Box from '@mui/material/Box'
import Button from '@mui/material/Button'
import CircularProgress from '@mui/material/CircularProgress'
import List from '@mui/material/List'
import ListItemButton from '@mui/material/ListItemButton'
import ListItemText from '@mui/material/ListItemText'
import Stack from '@mui/material/Stack'
import TextField from '@mui/material/TextField'
import Typography from '@mui/material/Typography'
import * as maplibregl from 'maplibre-gl'
import Map, { Marker } from 'react-map-gl/maplibre'
import type { MapMouseEvent } from 'react-map-gl/maplibre'
type NominatimItem = { display_name: string; lat: string; lon: string }
async function reverseGeocode(pos: { lat: number; lng: number }): Promise<string | null> {
const url = new URL('https://nominatim.openstreetmap.org/reverse')
url.searchParams.set('format', 'jsonv2')
url.searchParams.set('lat', String(pos.lat))
url.searchParams.set('lon', String(pos.lng))
url.searchParams.set('accept-language', 'ru')
const res = await fetch(url.toString(), { headers: { 'User-Agent': 'craftshop-demo' } })
if (!res.ok) return null
const data = (await res.json()) as { display_name?: string }
return data.display_name ? String(data.display_name) : null
}
type LatLng = { lat: number; lng: number }
async function searchPlaces(q: string, signal?: AbortSignal): Promise<NominatimItem[]> {
const url = new URL('https://nominatim.openstreetmap.org/search')
url.searchParams.set('format', 'jsonv2')
url.searchParams.set('q', q)
url.searchParams.set('accept-language', 'ru')
url.searchParams.set('limit', '5')
const res = await fetch(url.toString(), {
headers: { 'User-Agent': 'craftshop-demo' },
signal,
})
if (!res.ok) return []
const data = (await res.json()) as NominatimItem[]
return Array.isArray(data) ? data : []
}
export function AddressMapPicker(props: {
value: { lat: number; lng: number } | null
onChange: (v: { lat: number; lng: number; addressLine?: string | null }) => void
}) {
const { value, onChange } = props
const [q, setQ] = useState('')
const [searching, setSearching] = useState(false)
const [results, setResults] = useState<NominatimItem[]>([])
const [hint, setHint] = useState<string | null>(null)
const abortRef = useRef<AbortController | null>(null)
const lastQueryRef = useRef<string>('')
const lastRequestAtRef = useRef<number>(0)
const qTrimmed = q.trim()
const visibleResults = qTrimmed.length >= 3 ? results : []
const center = useMemo(() => {
if (value) return { lat: value.lat, lng: value.lng }
return { lat: 55.751244, lng: 37.618423 } // Москва (fallback)
}, [value])
const pick = async (pos: LatLng) => {
setHint(null)
onChange({ lat: pos.lat, lng: pos.lng })
try {
const addr = await reverseGeocode(pos)
if (addr) {
setHint(addr)
onChange({ lat: pos.lat, lng: pos.lng, addressLine: addr })
}
} catch {
// ignore
}
}
useEffect(() => {
const s = qTrimmed
if (s.length < 3) {
return
}
const t = window.setTimeout(async () => {
// throttle: не чаще 1 запроса в 900ms
const now = Date.now()
if (now - lastRequestAtRef.current < 900) return
if (s === lastQueryRef.current) return
lastQueryRef.current = s
lastRequestAtRef.current = now
abortRef.current?.abort()
const ac = new AbortController()
abortRef.current = ac
setSearching(true)
try {
setResults(await searchPlaces(s, ac.signal))
} catch (e) {
if ((e as { name?: string })?.name !== 'AbortError') {
setResults([])
}
} finally {
setSearching(false)
}
}, 450)
return () => {
window.clearTimeout(t)
}
}, [qTrimmed])
return (
<Stack spacing={1.5}>
<Typography variant="subtitle2">Выбор на карте</Typography>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={1.5}>
<TextField size="small" label="Найти адрес" value={q} onChange={(e) => setQ(e.target.value)} fullWidth />
<Button
variant="outlined"
onClick={async () => {
const s = q.trim()
if (!s) return
abortRef.current?.abort()
const ac = new AbortController()
abortRef.current = ac
setSearching(true)
try {
lastQueryRef.current = s
lastRequestAtRef.current = Date.now()
setResults(await searchPlaces(s, ac.signal))
} finally {
setSearching(false)
}
}}
disabled={searching || !q.trim()}
sx={{ minWidth: 160 }}
>
{searching ? <CircularProgress size={18} /> : 'Найти'}
</Button>
</Stack>
{visibleResults.length > 0 && (
<List dense sx={{ border: 1, borderColor: 'divider', borderRadius: 2 }}>
{visibleResults.map((r) => (
<ListItemButton
key={`${r.lat}:${r.lon}:${r.display_name}`}
onClick={() => {
const lat = Number(r.lat)
const lng = Number(r.lon)
if (!Number.isFinite(lat) || !Number.isFinite(lng)) return
void pick({ lat, lng })
}}
>
<ListItemText primary={r.display_name} />
</ListItemButton>
))}
</List>
)}
<Box
sx={{
height: 280,
borderRadius: 2,
overflow: 'hidden',
border: 1,
borderColor: 'divider',
}}
>
<Map
mapLib={maplibregl}
initialViewState={{ latitude: center.lat, longitude: center.lng, zoom: 12 }}
style={{ width: '100%', height: 280 }}
mapStyle={{
version: 8,
sources: {
osm: {
type: 'raster',
tiles: ['https://tile.openstreetmap.org/{z}/{x}/{y}.png'],
tileSize: 256,
attribution: '© OpenStreetMap contributors',
},
},
layers: [{ id: 'osm', type: 'raster', source: 'osm' }],
}}
onClick={(e: MapMouseEvent) => {
const { lng, lat } = e.lngLat
void pick({ lat, lng })
}}
>
{value && (
<Marker
longitude={value.lng}
latitude={value.lat}
draggable
onDragEnd={(e) => {
const { lng, lat } = e.lngLat
void pick({ lat, lng })
}}
anchor="bottom"
>
<Box
sx={{
width: 18,
height: 18,
bgcolor: 'primary.main',
borderRadius: '50%',
border: 2,
borderColor: 'background.paper',
boxShadow: 3,
}}
/>
</Marker>
)}
</Map>
</Box>
<Box sx={{ minHeight: 18 }}>
{hint && (
<Typography variant="caption" color="text.secondary">
Подсказка адреса: {hint}
</Typography>
)}
</Box>
</Stack>
)
}
+1
View File
@@ -2,6 +2,7 @@ import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { App } from '@/app/App'
import '@/app/styles/global.css'
import 'maplibre-gl/dist/maplibre-gl.css'
import { readStoredToken, tokenSet } from '@/shared/model/auth'
tokenSet(readStoredToken())
+14 -3
View File
@@ -1,4 +1,4 @@
import { useState } from 'react'
import { useEffect, useState } from 'react'
import Alert from '@mui/material/Alert'
import Box from '@mui/material/Box'
import Button from '@mui/material/Button'
@@ -8,10 +8,12 @@ import TextField from '@mui/material/TextField'
import Typography from '@mui/material/Typography'
import { useMutation } from '@tanstack/react-query'
import { useForm } from 'react-hook-form'
import { useUnit } from 'effector-react'
import { useNavigate } from 'react-router-dom'
import { apiClient } from '@/shared/api/client'
import { tokenSet } from '@/shared/model/auth'
import { $user, tokenSet } from '@/shared/model/auth'
type AuthResponse = { token: string; user: { id: string; email: string } }
type AuthResponse = { token: string; user: { id: string; email: string; name?: string | null; phone?: string | null } }
function getApiErrorMessage(err: unknown): string | null {
if (!err || typeof err !== 'object') return null
@@ -24,6 +26,8 @@ function getApiErrorMessage(err: unknown): string | null {
export function AuthPage() {
const [message, setMessage] = useState<string | null>(null)
const navigate = useNavigate()
const user = useUnit($user)
const { register, watch } = useForm<{
email: string
code: string
@@ -37,6 +41,10 @@ export function AuthPage() {
const code = watch('code')
const password = watch('password')
useEffect(() => {
if (user) navigate('/', { replace: true })
}, [navigate, user])
const requestCode = useMutation({
mutationFn: async () => {
await apiClient.post('auth/request-code', { email })
@@ -49,6 +57,7 @@ export function AuthPage() {
const { data } = await apiClient.post<AuthResponse>('auth/verify-code', { email, code })
tokenSet(data.token)
setMessage(`Вход выполнен: ${data.user.email}`)
navigate('/', { replace: true })
},
})
@@ -57,6 +66,7 @@ export function AuthPage() {
const { data } = await apiClient.post<AuthResponse>('auth/register', { email, password })
tokenSet(data.token)
setMessage(`Регистрация выполнена: ${data.user.email}`)
navigate('/', { replace: true })
},
})
@@ -65,6 +75,7 @@ export function AuthPage() {
const { data } = await apiClient.post<AuthResponse>('auth/login', { email, password })
tokenSet(data.token)
setMessage(`Вход выполнен: ${data.user.email}`)
navigate('/', { replace: true })
},
})
+53 -19
View File
@@ -3,11 +3,13 @@ import Alert from '@mui/material/Alert'
import Box from '@mui/material/Box'
import Button from '@mui/material/Button'
import Collapse from '@mui/material/Collapse'
import Divider from '@mui/material/Divider'
import FormControl from '@mui/material/FormControl'
import Grid from '@mui/material/Grid'
import InputLabel from '@mui/material/InputLabel'
import MenuItem from '@mui/material/MenuItem'
import Pagination from '@mui/material/Pagination'
import Paper from '@mui/material/Paper'
import Select from '@mui/material/Select'
import type { SelectChangeEvent } from '@mui/material/Select'
import Skeleton from '@mui/material/Skeleton'
@@ -235,26 +237,58 @@ export function HomePage() {
))}
</Select>
</FormControl>
<Box sx={{ flexGrow: 1, minWidth: { xs: '100%', md: 260 } }}>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.5 }}>
Масштаб карточек
</Typography>
<ToggleButtonGroup
exclusive
size="small"
value={cardScale}
onChange={(_, v) => {
if (v === 70 || v === 90 || v === 110 || v === 130) setCardScale(v)
}}
>
<ToggleButton value={70}>S</ToggleButton>
<ToggleButton value={90}>M</ToggleButton>
<ToggleButton value={110}>L</ToggleButton>
<ToggleButton value={130}>XL</ToggleButton>
</ToggleButtonGroup>
</Box>
</Stack>
<Divider sx={{ my: 2 }} />
<Paper
variant="outlined"
sx={{
p: 1.5,
borderRadius: 2,
bgcolor: 'background.paper',
display: 'flex',
flexDirection: { xs: 'column', sm: 'row' },
gap: 1.5,
alignItems: { sm: 'center' },
justifyContent: 'space-between',
}}
>
<Box>
<Typography variant="subtitle2">Масштаб карточек</Typography>
<Typography variant="caption" color="text.secondary">
Выберите размер карточек в каталоге
</Typography>
</Box>
<ToggleButtonGroup
exclusive
size="small"
value={cardScale}
onChange={(_, v) => {
if (v === 70 || v === 90 || v === 110 || v === 130) setCardScale(v)
}}
sx={{
alignSelf: { xs: 'flex-start', sm: 'auto' },
'& .MuiToggleButton-root': {
px: 2,
fontWeight: 700,
letterSpacing: 0.2,
textTransform: 'none',
},
'& .MuiToggleButton-root.Mui-selected': {
bgcolor: 'primary.main',
color: 'primary.contrastText',
'&:hover': { bgcolor: 'primary.dark' },
},
}}
>
<ToggleButton value={70}>S</ToggleButton>
<ToggleButton value={90}>M</ToggleButton>
<ToggleButton value={110}>L</ToggleButton>
<ToggleButton value={130}>XL</ToggleButton>
</ToggleButtonGroup>
</Paper>
</Collapse>
</Stack>
+1 -1
View File
@@ -1 +1 @@
export { MePage } from './ui/MePage'
export { MeLayoutPage } from './ui/MeLayoutPage'
+136
View File
@@ -0,0 +1,136 @@
import type { ReactNode } from 'react'
import { useMemo, useState } from 'react'
import ChatOutlinedIcon from '@mui/icons-material/ChatOutlined'
import LocalShippingOutlinedIcon from '@mui/icons-material/LocalShippingOutlined'
import MenuOutlinedIcon from '@mui/icons-material/MenuOutlined'
import PlaceOutlinedIcon from '@mui/icons-material/PlaceOutlined'
import SettingsOutlinedIcon from '@mui/icons-material/SettingsOutlined'
import Alert from '@mui/material/Alert'
import Box from '@mui/material/Box'
import Divider from '@mui/material/Divider'
import Drawer from '@mui/material/Drawer'
import IconButton from '@mui/material/IconButton'
import List from '@mui/material/List'
import ListItemButton from '@mui/material/ListItemButton'
import ListItemIcon from '@mui/material/ListItemIcon'
import ListItemText from '@mui/material/ListItemText'
import Stack from '@mui/material/Stack'
import { useTheme } from '@mui/material/styles'
import Typography from '@mui/material/Typography'
import useMediaQuery from '@mui/material/useMediaQuery'
import { useUnit } from 'effector-react'
import { Navigate, Route, Routes, useLocation, useNavigate } from 'react-router-dom'
import { AddressesPage } from '@/pages/me/ui/sections/AddressesPage'
import { MessagesPage } from '@/pages/me/ui/sections/MessagesPage'
import { OrdersPage } from '@/pages/me/ui/sections/OrdersPage'
import { SettingsPage } from '@/pages/me/ui/sections/SettingsPage'
import { $user } from '@/shared/model/auth'
type NavItem = {
to: string
label: string
icon: ReactNode
}
export function MeLayoutPage() {
const user = useUnit($user)
const navigate = useNavigate()
const location = useLocation()
const theme = useTheme()
const isMobile = useMediaQuery(theme.breakpoints.down('md'))
const [mobileOpen, setMobileOpen] = useState(false)
const navItems: NavItem[] = useMemo(
() => [
{ to: '/me/orders', label: 'Заказы', icon: <LocalShippingOutlinedIcon /> },
{ to: '/me/messages', label: 'Сообщения', icon: <ChatOutlinedIcon /> },
{ to: '/me/settings', label: 'Настройки', icon: <SettingsOutlinedIcon /> },
{ to: '/me/addresses', label: 'Адреса доставки', icon: <PlaceOutlinedIcon /> },
],
[],
)
if (!user) {
return <Alert severity="info">Нужно войти. Перейдите на страницу «Вход».</Alert>
}
const activeTo =
navItems.find((x) => location.pathname === x.to)?.to ??
navItems.find((x) => location.pathname.startsWith(`${x.to}/`))?.to ??
null
const nav = (
<Box sx={{ width: 280, maxWidth: '85vw' }}>
<Box sx={{ p: 2 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
Кабинет
</Typography>
<Typography variant="body2" color="text.secondary">
{user.name?.trim() || user.email}
</Typography>
</Box>
<Divider />
<List disablePadding>
{navItems.map((i) => (
<ListItemButton
key={i.to}
selected={activeTo === i.to}
onClick={() => {
navigate(i.to)
setMobileOpen(false)
}}
>
<ListItemIcon>{i.icon}</ListItemIcon>
<ListItemText primary={i.label} />
</ListItemButton>
))}
</List>
</Box>
)
return (
<Stack direction={{ xs: 'column', md: 'row' }} spacing={3} sx={{ alignItems: 'flex-start' }}>
{isMobile ? (
<>
<Stack direction="row" spacing={1} alignItems="center" sx={{ width: '100%' }}>
<IconButton onClick={() => setMobileOpen(true)} aria-label="Открыть меню профиля">
<MenuOutlinedIcon />
</IconButton>
<Typography variant="h5" sx={{ fontWeight: 700 }}>
Профиль
</Typography>
</Stack>
<Drawer open={mobileOpen} onClose={() => setMobileOpen(false)} ModalProps={{ keepMounted: true }}>
{nav}
</Drawer>
</>
) : (
<Box
sx={{
position: 'sticky',
top: 88,
alignSelf: 'flex-start',
border: 1,
borderColor: 'divider',
borderRadius: 2,
bgcolor: 'background.paper',
overflow: 'hidden',
}}
>
{nav}
</Box>
)}
<Box sx={{ flexGrow: 1, minWidth: 0, width: '100%' }}>
<Routes>
<Route index element={<Navigate to="/me/settings" replace />} />
<Route path="orders" element={<OrdersPage />} />
<Route path="messages" element={<MessagesPage />} />
<Route path="settings" element={<SettingsPage />} />
<Route path="addresses" element={<AddressesPage />} />
<Route path="*" element={<Navigate to="/me/settings" replace />} />
</Routes>
</Box>
</Stack>
)
}
@@ -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>
)
}
+4 -2
View File
@@ -1,7 +1,7 @@
import { createEffect, createEvent, createStore, sample } from 'effector'
import { apiClient } from '@/shared/api/client'
export type AuthUser = { id: string; email: string; name?: string | null }
export type AuthUser = { id: string; email: string; name?: string | null; phone?: string | null }
const TOKEN_KEY = 'craftshop_auth_token'
@@ -28,7 +28,9 @@ export const verifyEmailChangeFx = createEffect(async (params: { newEmail: strin
return data.user
})
export const updateProfileFx = createEffect(async (params: { name: string | null }) => {
export type UpdateProfileParams = { name: string | null; phone?: string | null }
export const updateProfileFx = createEffect(async (params: UpdateProfileParams) => {
const { data } = await apiClient.patch<{ user: AuthUser }>('me/profile', params)
return data.user
})