base commit

This commit is contained in:
@kirill.komarov
2026-04-29 19:14:34 +05:00
parent c1773e5c57
commit bfc9661d22
25 changed files with 1885 additions and 3 deletions
+8
View File
@@ -2,8 +2,12 @@ import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'
import { MainLayout } from '@/app/layout/MainLayout'
import { AppProviders } from '@/app/providers/AppProviders'
import { AdminPage } from '@/pages/admin'
import { AdminOrdersPage } from '@/pages/admin-orders'
import { AdminReviewsPage } from '@/pages/admin-reviews'
import { AdminUsersPage } from '@/pages/admin-users'
import { AuthPage } from '@/pages/auth'
import { CartPage } from '@/pages/cart'
import { CheckoutPage } from '@/pages/checkout'
import { HomePage } from '@/pages/home'
import { MeLayoutPage } from '@/pages/me'
import { ProductPage } from '@/pages/product'
@@ -16,8 +20,12 @@ export function App() {
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/admin" element={<AdminPage />} />
<Route path="/admin/orders" element={<AdminOrdersPage />} />
<Route path="/admin/reviews" element={<AdminReviewsPage />} />
<Route path="/admin/users" element={<AdminUsersPage />} />
<Route path="/auth" element={<AuthPage />} />
<Route path="/cart" element={<CartPage />} />
<Route path="/checkout" element={<CheckoutPage />} />
<Route path="/me/*" element={<MeLayoutPage />} />
<Route path="/products/:id" element={<ProductPage />} />
<Route path="*" element={<Navigate to="/" replace />} />
+1
View File
@@ -33,6 +33,7 @@ type NavItem = { label: string; to: string }
const navItems: NavItem[] = [
{ label: 'Каталог', to: '/' },
{ label: 'Корзина', to: '/cart' },
{ label: 'Админка', to: '/admin' },
]
+21
View File
@@ -0,0 +1,21 @@
import type { CartItem } from '@/entities/cart/model/types'
import { apiClient } from '@/shared/api/client'
export type CartResponse = { items: CartItem[] }
export async function fetchMyCart(): Promise<CartResponse> {
const { data } = await apiClient.get<CartResponse>('me/cart')
return data
}
export async function addToCart(body: { productId: string; qty?: number }): Promise<void> {
await apiClient.post('me/cart/items', body)
}
export async function setCartQty(id: string, qty: number): Promise<void> {
await apiClient.patch(`me/cart/items/${id}`, { qty })
}
export async function removeCartItem(id: string): Promise<void> {
await apiClient.delete(`me/cart/items/${id}`)
}
+7
View File
@@ -0,0 +1,7 @@
import type { Product } from '@/entities/product/model/types'
export type CartItem = {
id: string
qty: number
product: Product
}
@@ -0,0 +1,84 @@
import { apiClient } from '@/shared/api/client'
export type AdminOrderListItem = {
id: string
status: string
totalCents: number
currency: string
createdAt: string
updatedAt: string
user: { id: string; email: string }
itemsCount: number
}
export type AdminOrdersListResponse = {
items: AdminOrderListItem[]
total: number
page: number
pageSize: number
}
export type AdminOrderDetailResponse = {
item: {
id: string
status: string
totalCents: number
currency: string
addressSnapshotJson: string
comment: string | null
createdAt: string
updatedAt: string
user: { id: string; email: string; name: string | null; phone: string | null }
items: Array<{
id: string
productId: string
qty: number
titleSnapshot: string
priceCentsSnapshot: number
}>
messages: Array<{
id: string
authorType: string
text: string
createdAt: string
}>
}
}
export async function fetchAdminOrders(
token: string,
params?: { status?: string; q?: string; page?: number; pageSize?: number },
): Promise<AdminOrdersListResponse> {
const { data } = await apiClient.get<AdminOrdersListResponse>('admin/orders', {
params,
headers: { Authorization: `Bearer ${token}` },
})
return data
}
export async function fetchAdminOrder(token: string, id: string): Promise<AdminOrderDetailResponse> {
const { data } = await apiClient.get<AdminOrderDetailResponse>(`admin/orders/${id}`, {
headers: { Authorization: `Bearer ${token}` },
})
return data
}
export async function setAdminOrderStatus(token: string, id: string, status: string): Promise<void> {
await apiClient.patch(
`admin/orders/${id}/status`,
{ status },
{
headers: { Authorization: `Bearer ${token}` },
},
)
}
export async function postAdminOrderMessage(token: string, id: string, text: string): Promise<void> {
await apiClient.post(
`admin/orders/${id}/messages`,
{ text },
{
headers: { Authorization: `Bearer ${token}` },
},
)
}
@@ -0,0 +1,62 @@
import { apiClient } from '@/shared/api/client'
export type OrderListItem = {
id: string
status: string
totalCents: number
currency: string
createdAt: string
updatedAt: string
itemsCount: number
}
export type OrderListResponse = { items: OrderListItem[] }
export type OrderDetailResponse = {
item: {
id: string
status: string
totalCents: number
currency: string
addressSnapshotJson: string
comment: string | null
createdAt: string
updatedAt: string
items: Array<{
id: string
productId: string
qty: number
titleSnapshot: string
priceCentsSnapshot: number
}>
messages: Array<{
id: string
authorType: string
text: string
createdAt: string
}>
}
}
export async function createOrder(body: { addressId: string; comment?: string | null }): Promise<{ orderId: string }> {
const { data } = await apiClient.post<{ orderId: string }>('me/orders', body)
return data
}
export async function fetchMyOrders(): Promise<OrderListResponse> {
const { data } = await apiClient.get<OrderListResponse>('me/orders')
return data
}
export async function fetchMyOrder(id: string): Promise<OrderDetailResponse> {
const { data } = await apiClient.get<OrderDetailResponse>(`me/orders/${id}`)
return data
}
export async function postOrderMessage(id: string, text: string): Promise<void> {
await apiClient.post(`me/orders/${id}/messages`, { text })
}
export async function payOrderStub(id: string): Promise<void> {
await apiClient.post(`me/orders/${id}/pay`)
}
@@ -1,4 +1,5 @@
import { useMemo, useRef } from 'react'
import Button from '@mui/material/Button'
import Card from '@mui/material/Card'
import CardContent from '@mui/material/CardContent'
import CardMedia from '@mui/material/CardMedia'
@@ -7,16 +8,22 @@ import Box from '@mui/material/Box'
import Link from '@mui/material/Link'
import Stack from '@mui/material/Stack'
import Typography from '@mui/material/Typography'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { Swiper, SwiperSlide } from 'swiper/react'
import type { Swiper as SwiperType } from 'swiper/types'
import 'swiper/css'
import { Link as RouterLink } from 'react-router-dom'
import { useUnit } from 'effector-react'
import { addToCart } from '@/entities/cart/api/cart-api'
import type { Product } from '@/entities/product/model/types'
import { formatPriceRub } from '@/shared/lib/format-price'
import { $user } from '@/shared/model/auth'
type Props = { product: Product; mediaHeight?: number }
export function ProductCard({ product, mediaHeight = 200 }: Props) {
const qc = useQueryClient()
const user = useUnit($user)
const swiperRef = useRef<SwiperType | null>(null)
const imageUrls = useMemo(() => {
const fromImages = (product.images ?? [])
@@ -39,6 +46,11 @@ export function ProductCard({ product, mediaHeight = 200 }: Props) {
swiperRef.current.slideTo(idx, 0)
}
const addMut = useMutation({
mutationFn: () => addToCart({ productId: product.id, qty: 1 }),
onSuccess: () => void qc.invalidateQueries({ queryKey: ['me', 'cart'] }),
})
return (
<Card
variant="outlined"
@@ -148,6 +160,18 @@ export function ProductCard({ product, mediaHeight = 200 }: Props) {
<Typography variant="h6" color="primary">
{formatPriceRub(product.priceCents)}
</Typography>
<Button
variant="contained"
size="small"
disabled={!user || addMut.isPending}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
addMut.mutate()
}}
>
{user ? 'В корзину' : 'Войдите, чтобы купить'}
</Button>
</Stack>
</CardContent>
</Card>
@@ -0,0 +1,40 @@
import { apiClient } from '@/shared/api/client'
export type AdminReview = {
id: string
rating: number
text: string | null
status: string
createdAt: string
moderatedAt: string | null
user: { id: string; email: string; name: string | null }
product: { id: string; title: string }
}
export type AdminReviewsListResponse = {
items: AdminReview[]
total: number
page: number
pageSize: number
}
export async function fetchAdminReviews(
token: string,
params?: { status?: string; page?: number; pageSize?: number },
): Promise<AdminReviewsListResponse> {
const { data } = await apiClient.get<AdminReviewsListResponse>('admin/reviews', {
params,
headers: { Authorization: `Bearer ${token}` },
})
return data
}
export async function moderateReview(token: string, id: string, action: 'approve' | 'reject'): Promise<void> {
await apiClient.patch(
`admin/reviews/${id}`,
{ action },
{
headers: { Authorization: `Bearer ${token}` },
},
)
}
+1
View File
@@ -0,0 +1 @@
export { AdminOrdersPage } from './ui/AdminOrdersPage'
@@ -0,0 +1,301 @@
import { useEffect, useMemo, useState } from 'react'
import Alert from '@mui/material/Alert'
import Box from '@mui/material/Box'
import Button from '@mui/material/Button'
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 FormControl from '@mui/material/FormControl'
import InputLabel from '@mui/material/InputLabel'
import MenuItem from '@mui/material/MenuItem'
import Select from '@mui/material/Select'
import Stack from '@mui/material/Stack'
import Table from '@mui/material/Table'
import TableBody from '@mui/material/TableBody'
import TableCell from '@mui/material/TableCell'
import TableHead from '@mui/material/TableHead'
import TableRow from '@mui/material/TableRow'
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 { Link as RouterLink } from 'react-router-dom'
import {
fetchAdminOrder,
fetchAdminOrders,
postAdminOrderMessage,
setAdminOrderStatus,
} from '@/entities/order/api/admin-order-api'
import { clearAdminToken, getAdminToken, setAdminToken } from '@/shared/lib/admin-token'
import { formatPriceRub } from '@/shared/lib/format-price'
type TokenFormState = { token: string }
export function AdminOrdersPage() {
const qc = useQueryClient()
const [token, setTokenState] = useState<string | null>(() => getAdminToken())
const [q, setQ] = useState('')
const [status, setStatus] = useState('')
const [dialogOpen, setDialogOpen] = useState(false)
const [selectedId, setSelectedId] = useState<string | null>(null)
const [msg, setMsg] = useState('')
const tokenForm = useForm<TokenFormState>({ defaultValues: { token: '' }, mode: 'onChange' })
useEffect(() => {
tokenForm.reset({ token: '' })
}, [token, tokenForm])
const saveToken = () => {
const t = tokenForm.getValues('token').trim()
if (!t) {
clearAdminToken()
setTokenState(null)
return
}
setAdminToken(t)
setTokenState(t)
}
const ordersQuery = useQuery({
queryKey: ['admin', 'orders', token, { q, status }],
queryFn: () => fetchAdminOrders(token!, { q: q.trim() || undefined, status: status || undefined }),
enabled: Boolean(token),
})
const orderDetailQuery = useQuery({
queryKey: ['admin', 'orders', 'detail', token, selectedId],
queryFn: () => fetchAdminOrder(token!, selectedId!),
enabled: Boolean(token && selectedId),
})
const statusMut = useMutation({
mutationFn: (next: string) => setAdminOrderStatus(token!, selectedId!, next),
onSuccess: async () => {
await qc.invalidateQueries({ queryKey: ['admin', 'orders'] })
await qc.invalidateQueries({ queryKey: ['admin', 'orders', 'detail'] })
},
})
const msgMut = useMutation({
mutationFn: () => postAdminOrderMessage(token!, selectedId!, msg.trim()),
onSuccess: async () => {
setMsg('')
await qc.invalidateQueries({ queryKey: ['admin', 'orders', 'detail'] })
},
})
const open = (id: string) => {
setSelectedId(id)
setDialogOpen(true)
}
const items = ordersQuery.data?.items ?? []
const detail = orderDetailQuery.data?.item
const nextStatuses = useMemo(() => {
const s = detail?.status
if (!s) return []
const map: Record<string, string[]> = {
DRAFT: ['PENDING_PAYMENT', 'CANCELLED'],
PENDING_PAYMENT: ['PAID', 'CANCELLED'],
PAID: ['IN_PROGRESS', 'CANCELLED'],
IN_PROGRESS: ['SHIPPED', 'CANCELLED'],
SHIPPED: ['DONE'],
}
return map[s] ?? []
}, [detail?.status])
return (
<Box>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} sx={{ mb: 2, alignItems: { sm: 'center' } }}>
<Typography variant="h4" sx={{ flexGrow: 1 }}>
Админка заказы
</Typography>
<Button component={RouterLink} to="/admin" variant="outlined">
Товары
</Button>
<Button component={RouterLink} to="/admin/reviews" variant="outlined">
Отзывы
</Button>
</Stack>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
Введите API-токен из <code>ADMIN_API_TOKEN</code> (сохраняется в sessionStorage).
</Typography>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} sx={{ mb: 2 }}>
<Controller
control={tokenForm.control}
name="token"
render={({ field }) => (
<TextField
label="Токен (Bearer)"
type="password"
fullWidth
{...field}
placeholder={token ? '••••••••' : ''}
/>
)}
/>
<Button variant="contained" onClick={saveToken} sx={{ minWidth: 140 }}>
Сохранить
</Button>
</Stack>
{!token && <Alert severity="info">После сохранения токена появится список заказов.</Alert>}
{token && (
<>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} sx={{ mb: 2 }}>
<TextField
size="small"
label="Поиск (id/email)"
value={q}
onChange={(e) => setQ(e.target.value)}
fullWidth
/>
<FormControl size="small" sx={{ minWidth: 220 }}>
<InputLabel id="status-label">Статус</InputLabel>
<Select
labelId="status-label"
label="Статус"
value={status}
onChange={(e) => setStatus(String(e.target.value))}
>
<MenuItem value="">
<em>Все</em>
</MenuItem>
{['DRAFT', 'PENDING_PAYMENT', 'PAID', 'IN_PROGRESS', 'SHIPPED', 'DONE', 'CANCELLED'].map((s) => (
<MenuItem key={s} value={s}>
{s}
</MenuItem>
))}
</Select>
</FormControl>
</Stack>
{ordersQuery.isError && <Alert severity="error">Не удалось загрузить заказы.</Alert>}
<Table size="small">
<TableHead>
<TableRow>
<TableCell>ID</TableCell>
<TableCell>Покупатель</TableCell>
<TableCell>Статус</TableCell>
<TableCell>Сумма</TableCell>
<TableCell>Позиций</TableCell>
<TableCell align="right">Действия</TableCell>
</TableRow>
</TableHead>
<TableBody>
{items.map((o) => (
<TableRow key={o.id} hover>
<TableCell>{o.id.slice(-8)}</TableCell>
<TableCell>{o.user.email}</TableCell>
<TableCell>{o.status}</TableCell>
<TableCell>{formatPriceRub(o.totalCents)}</TableCell>
<TableCell>{o.itemsCount}</TableCell>
<TableCell align="right">
<Button size="small" onClick={() => open(o.id)}>
Открыть
</Button>
</TableCell>
</TableRow>
))}
{ordersQuery.isSuccess && items.length === 0 && (
<TableRow>
<TableCell colSpan={6} sx={{ color: 'text.secondary' }}>
Заказов пока нет.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</>
)}
<Dialog open={dialogOpen} onClose={() => setDialogOpen(false)} fullWidth maxWidth="md">
<DialogTitle>Заказ</DialogTitle>
<DialogContent>
{!detail && orderDetailQuery.isLoading && <Typography>Загрузка</Typography>}
{orderDetailQuery.isError && <Alert severity="error">Не удалось загрузить заказ.</Alert>}
{detail && (
<Stack spacing={2} sx={{ mt: 1 }}>
<Typography sx={{ fontWeight: 700 }}>
#{detail.id.slice(-8)} · {detail.user.email} · {detail.status} · {formatPriceRub(detail.totalCents)}
</Typography>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} alignItems={{ sm: 'center' }}>
<FormControl size="small" sx={{ minWidth: 240 }}>
<InputLabel id="next-status-label">Сменить статус</InputLabel>
<Select
labelId="next-status-label"
label="Сменить статус"
value=""
onChange={(e) => {
const next = String(e.target.value)
if (!next) return
statusMut.mutate(next)
}}
disabled={statusMut.isPending || nextStatuses.length === 0}
>
<MenuItem value="">
<em>Выберите</em>
</MenuItem>
{nextStatuses.map((s) => (
<MenuItem key={s} value={s}>
{s}
</MenuItem>
))}
</Select>
</FormControl>
</Stack>
<Box>
<Typography variant="subtitle2" sx={{ mb: 1 }}>
Сообщения
</Typography>
<Stack spacing={1} sx={{ mb: 1 }}>
{detail.messages.map((m) => (
<Box key={m.id} sx={{ p: 1, border: 1, borderColor: 'divider', borderRadius: 2 }}>
<Typography variant="caption" color="text.secondary">
{m.authorType} · {new Date(m.createdAt).toLocaleString()}
</Typography>
<Typography sx={{ whiteSpace: 'pre-wrap' }}>{m.text}</Typography>
</Box>
))}
{detail.messages.length === 0 && <Typography color="text.secondary">Нет сообщений.</Typography>}
</Stack>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} alignItems={{ sm: 'flex-end' }}>
<TextField
label="Ответ админа"
value={msg}
onChange={(e) => setMsg(e.target.value)}
fullWidth
multiline
minRows={2}
/>
<Button
variant="contained"
onClick={() => msgMut.mutate()}
disabled={msgMut.isPending || !msg.trim()}
sx={{ minWidth: 160 }}
>
Отправить
</Button>
</Stack>
</Box>
</Stack>
)}
</DialogContent>
<DialogActions>
<Button onClick={() => setDialogOpen(false)}>Закрыть</Button>
</DialogActions>
</Dialog>
</Box>
)
}
+1
View File
@@ -0,0 +1 @@
export { AdminReviewsPage } from './ui/AdminReviewsPage'
@@ -0,0 +1,153 @@
import { useEffect, 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 Stack from '@mui/material/Stack'
import Table from '@mui/material/Table'
import TableBody from '@mui/material/TableBody'
import TableCell from '@mui/material/TableCell'
import TableHead from '@mui/material/TableHead'
import TableRow from '@mui/material/TableRow'
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 { Link as RouterLink } from 'react-router-dom'
import { fetchAdminReviews, moderateReview } from '@/entities/review/api/admin-review-api'
import { clearAdminToken, getAdminToken, setAdminToken } from '@/shared/lib/admin-token'
type TokenFormState = { token: string }
export function AdminReviewsPage() {
const qc = useQueryClient()
const [token, setTokenState] = useState<string | null>(() => getAdminToken())
const tokenForm = useForm<TokenFormState>({ defaultValues: { token: '' }, mode: 'onChange' })
useEffect(() => {
tokenForm.reset({ token: '' })
}, [token, tokenForm])
const saveToken = () => {
const t = tokenForm.getValues('token').trim()
if (!t) {
clearAdminToken()
setTokenState(null)
return
}
setAdminToken(t)
setTokenState(t)
}
const reviewsQuery = useQuery({
queryKey: ['admin', 'reviews', token],
queryFn: () => fetchAdminReviews(token!, { status: 'pending', page: 1, pageSize: 50 }),
enabled: Boolean(token),
})
const modMut = useMutation({
mutationFn: (params: { id: string; action: 'approve' | 'reject' }) =>
moderateReview(token!, params.id, params.action),
onSuccess: () => void qc.invalidateQueries({ queryKey: ['admin', 'reviews'] }),
})
const error = modMut.error
const items = reviewsQuery.data?.items ?? []
return (
<Box>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} sx={{ mb: 2, alignItems: { sm: 'center' } }}>
<Typography variant="h4" sx={{ flexGrow: 1 }}>
Админка отзывы
</Typography>
<Button component={RouterLink} to="/admin" variant="outlined">
Товары
</Button>
<Button component={RouterLink} to="/admin/orders" variant="outlined">
Заказы
</Button>
</Stack>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
Введите API-токен из <code>ADMIN_API_TOKEN</code>.
</Typography>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} sx={{ mb: 2 }}>
<Controller
control={tokenForm.control}
name="token"
render={({ field }) => (
<TextField
label="Токен (Bearer)"
type="password"
fullWidth
{...field}
placeholder={token ? '••••••••' : ''}
/>
)}
/>
<Button variant="contained" onClick={saveToken} sx={{ minWidth: 140 }}>
Сохранить
</Button>
</Stack>
{!token && <Alert severity="info">После сохранения токена появится список отзывов на модерации.</Alert>}
{token && (
<>
{reviewsQuery.isError && <Alert severity="error">Не удалось загрузить отзывы.</Alert>}
{error && <Alert severity="error">{(error as Error).message}</Alert>}
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Товар</TableCell>
<TableCell>Пользователь</TableCell>
<TableCell>Оценка</TableCell>
<TableCell>Текст</TableCell>
<TableCell align="right">Действия</TableCell>
</TableRow>
</TableHead>
<TableBody>
{items.map((r) => (
<TableRow key={r.id} hover>
<TableCell>{r.product.title}</TableCell>
<TableCell>{r.user.email}</TableCell>
<TableCell>
<Chip label={String(r.rating)} size="small" />
</TableCell>
<TableCell>{r.text ?? '—'}</TableCell>
<TableCell align="right">
<Button
size="small"
onClick={() => modMut.mutate({ id: r.id, action: 'approve' })}
disabled={modMut.isPending}
>
Одобрить
</Button>
<Button
size="small"
color="error"
onClick={() => modMut.mutate({ id: r.id, action: 'reject' })}
disabled={modMut.isPending}
>
Отклонить
</Button>
</TableCell>
</TableRow>
))}
{reviewsQuery.isSuccess && items.length === 0 && (
<TableRow>
<TableCell colSpan={5} sx={{ color: 'text.secondary' }}>
Нет отзывов на модерации.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</>
)}
</Box>
)
}
+6
View File
@@ -328,6 +328,12 @@ export function AdminPage() {
<Button variant="outlined" onClick={() => setCatOpen(true)}>
Новая категория
</Button>
<Button component={RouterLink} to="/admin/orders" variant="outlined">
Заказы
</Button>
<Button component={RouterLink} to="/admin/reviews" variant="outlined">
Отзывы
</Button>
<Button component={RouterLink} to="/admin/users" variant="outlined">
Пользователи
</Button>
+1
View File
@@ -0,0 +1 @@
export { CartPage } from './ui/CartPage'
+127
View File
@@ -0,0 +1,127 @@
import AddIcon from '@mui/icons-material/Add'
import DeleteOutlineOutlinedIcon from '@mui/icons-material/DeleteOutlineOutlined'
import RemoveIcon from '@mui/icons-material/Remove'
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 IconButton from '@mui/material/IconButton'
import Stack from '@mui/material/Stack'
import Typography from '@mui/material/Typography'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useUnit } from 'effector-react'
import { Link as RouterLink } from 'react-router-dom'
import { fetchMyCart, removeCartItem, setCartQty } from '@/entities/cart/api/cart-api'
import { $user } from '@/shared/model/auth'
import { formatPriceRub } from '@/shared/lib/format-price'
export function CartPage() {
const user = useUnit($user)
const qc = useQueryClient()
const cartQuery = useQuery({
queryKey: ['me', 'cart'],
queryFn: fetchMyCart,
enabled: Boolean(user),
})
const qtyMut = useMutation({
mutationFn: (params: { id: string; qty: number }) => setCartQty(params.id, params.qty),
onSuccess: () => void qc.invalidateQueries({ queryKey: ['me', 'cart'] }),
})
const removeMut = useMutation({
mutationFn: (id: string) => removeCartItem(id),
onSuccess: () => void qc.invalidateQueries({ queryKey: ['me', 'cart'] }),
})
if (!user) {
return (
<Alert severity="info">
Чтобы пользоваться корзиной, нужно войти. Перейдите на страницу{' '}
<Typography component={RouterLink} to="/auth" sx={{ textDecoration: 'underline' }}>
Вход
</Typography>
.
</Alert>
)
}
const items = cartQuery.data?.items ?? []
const total = items.reduce((s, x) => s + x.product.priceCents * x.qty, 0)
return (
<Box>
<Typography variant="h4" gutterBottom>
Корзина
</Typography>
{cartQuery.isError && <Alert severity="error">Не удалось загрузить корзину.</Alert>}
{cartQuery.isSuccess && items.length === 0 && <Alert severity="info">Корзина пуста.</Alert>}
{items.length > 0 && (
<Stack spacing={2}>
{items.map((x) => (
<Box
key={x.id}
sx={{
border: 1,
borderColor: 'divider',
borderRadius: 2,
p: 2,
bgcolor: 'background.paper',
}}
>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} alignItems={{ sm: 'center' }}>
<Box sx={{ flexGrow: 1 }}>
<Typography sx={{ fontWeight: 700 }}>{x.product.title}</Typography>
<Typography color="text.secondary" variant="body2">
{formatPriceRub(x.product.priceCents)} · {x.qty} шт.
</Typography>
</Box>
<Stack direction="row" spacing={1} alignItems="center">
<IconButton
onClick={() => qtyMut.mutate({ id: x.id, qty: Math.max(0, x.qty - 1) })}
disabled={qtyMut.isPending}
aria-label="Уменьшить количество"
>
<RemoveIcon />
</IconButton>
<Typography sx={{ minWidth: 24, textAlign: 'center' }}>{x.qty}</Typography>
<IconButton
onClick={() => qtyMut.mutate({ id: x.id, qty: x.qty + 1 })}
disabled={qtyMut.isPending}
aria-label="Увеличить количество"
>
<AddIcon />
</IconButton>
<IconButton
onClick={() => removeMut.mutate(x.id)}
disabled={removeMut.isPending}
aria-label="Удалить"
>
<DeleteOutlineOutlinedIcon />
</IconButton>
</Stack>
</Stack>
</Box>
))}
<Divider />
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} alignItems={{ sm: 'center' }}>
<Typography variant="h6" sx={{ flexGrow: 1 }}>
Итого: {formatPriceRub(total)}
</Typography>
<Button component={RouterLink} to="/checkout" variant="contained">
Оформить заказ
</Button>
</Stack>
</Stack>
)}
</Box>
)
}
+1
View File
@@ -0,0 +1 @@
export { CheckoutPage } from './ui/CheckoutPage'
@@ -0,0 +1,132 @@
import Alert from '@mui/material/Alert'
import Box from '@mui/material/Box'
import Button from '@mui/material/Button'
import FormControl from '@mui/material/FormControl'
import InputLabel from '@mui/material/InputLabel'
import MenuItem from '@mui/material/MenuItem'
import Select from '@mui/material/Select'
import Stack from '@mui/material/Stack'
import TextField from '@mui/material/TextField'
import Typography from '@mui/material/Typography'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useState } from 'react'
import { Link as RouterLink, useNavigate } from 'react-router-dom'
import { useUnit } from 'effector-react'
import { fetchMyCart } from '@/entities/cart/api/cart-api'
import { createOrder } from '@/entities/order/api/order-api'
import { fetchMyAddresses } from '@/entities/user/api/address-api'
import { formatPriceRub } from '@/shared/lib/format-price'
import { $user } from '@/shared/model/auth'
export function CheckoutPage() {
const user = useUnit($user)
const qc = useQueryClient()
const navigate = useNavigate()
const [addressId, setAddressId] = useState('')
const [comment, setComment] = useState('')
const cartQuery = useQuery({
queryKey: ['me', 'cart'],
queryFn: fetchMyCart,
enabled: Boolean(user),
})
const addressesQuery = useQuery({
queryKey: ['me', 'addresses'],
queryFn: fetchMyAddresses,
enabled: Boolean(user),
})
const createMut = useMutation({
mutationFn: () => createOrder({ addressId, comment: comment.trim() || null }),
onSuccess: async (res) => {
await qc.invalidateQueries({ queryKey: ['me', 'cart'] })
navigate(`/me/orders/${res.orderId}`, { replace: true })
},
})
if (!user) {
return (
<Alert severity="info">
Чтобы оформить заказ, нужно войти. Перейдите на страницу{' '}
<Typography component={RouterLink} to="/auth" sx={{ textDecoration: 'underline' }}>
Вход
</Typography>
.
</Alert>
)
}
const items = cartQuery.data?.items ?? []
const total = items.reduce((s, x) => s + x.product.priceCents * x.qty, 0)
const addresses = addressesQuery.data?.items ?? []
const defaultAddr = addresses.find((a) => a.isDefault)
return (
<Box>
<Typography variant="h4" gutterBottom>
Оформление заказа
</Typography>
{cartQuery.isSuccess && items.length === 0 && (
<Alert severity="info" sx={{ mb: 2 }}>
Корзина пуста. Вернитесь в{' '}
<Typography component={RouterLink} to="/" sx={{ textDecoration: 'underline' }}>
каталог
</Typography>
.
</Alert>
)}
<Stack spacing={2} sx={{ maxWidth: 720 }}>
<FormControl size="small" fullWidth>
<InputLabel id="addr-label">Адрес доставки</InputLabel>
<Select
labelId="addr-label"
label="Адрес доставки"
value={addressId || (defaultAddr?.id ?? '')}
onChange={(e) => setAddressId(String(e.target.value))}
>
{addresses.map((a) => (
<MenuItem key={a.id} value={a.id}>
{(a.label?.trim() ? `${a.label}: ` : '') + a.addressLine}
</MenuItem>
))}
</Select>
</FormControl>
{addresses.length === 0 && (
<Alert severity="warning">
У вас нет адресов доставки. Добавьте адрес в{' '}
<Typography component={RouterLink} to="/me/addresses" sx={{ textDecoration: 'underline' }}>
кабинете
</Typography>
.
</Alert>
)}
<TextField
label="Комментарий к заказу (необязательно)"
value={comment}
onChange={(e) => setComment(e.target.value)}
fullWidth
multiline
minRows={2}
/>
<Typography variant="h6">Итого: {formatPriceRub(total)}</Typography>
<Button
variant="contained"
disabled={items.length === 0 || addresses.length === 0 || createMut.isPending}
onClick={() => createMut.mutate()}
>
Создать заказ
</Button>
{createMut.isError && <Alert severity="error">{(createMut.error as Error).message}</Alert>}
</Stack>
</Box>
)
}
+2
View File
@@ -22,6 +22,7 @@ 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 { OrderDetailPage } from '@/pages/me/ui/sections/OrderDetailPage'
import { OrdersPage } from '@/pages/me/ui/sections/OrdersPage'
import { SettingsPage } from '@/pages/me/ui/sections/SettingsPage'
import { $user } from '@/shared/model/auth'
@@ -125,6 +126,7 @@ export function MeLayoutPage() {
<Routes>
<Route index element={<Navigate to="/me/settings" replace />} />
<Route path="orders" element={<OrdersPage />} />
<Route path="orders/:id" element={<OrderDetailPage />} />
<Route path="messages" element={<MessagesPage />} />
<Route path="settings" element={<SettingsPage />} />
<Route path="addresses" element={<AddressesPage />} />
@@ -0,0 +1,180 @@
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 { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useMemo, useState } from 'react'
import { Link as RouterLink, useParams } from 'react-router-dom'
import { fetchMyOrder, payOrderStub, postOrderMessage } from '@/entities/order/api/order-api'
import { formatPriceRub } from '@/shared/lib/format-price'
type AddressSnapshot = {
label?: string | null
recipientName?: string
recipientPhone?: string
addressLine?: string
comment?: string | null
}
export function OrderDetailPage() {
const { id } = useParams()
const qc = useQueryClient()
const [text, setText] = useState('')
const orderQuery = useQuery({
queryKey: ['me', 'orders', id],
queryFn: () => fetchMyOrder(id!),
enabled: Boolean(id),
})
const payMut = useMutation({
mutationFn: () => payOrderStub(id!),
onSuccess: () => void qc.invalidateQueries({ queryKey: ['me', 'orders', id] }),
})
const msgMut = useMutation({
mutationFn: () => postOrderMessage(id!, text.trim()),
onSuccess: async () => {
setText('')
await qc.invalidateQueries({ queryKey: ['me', 'orders', id] })
},
})
const order = orderQuery.data?.item
const address = useMemo((): AddressSnapshot | null => {
if (!order?.addressSnapshotJson) return null
try {
return JSON.parse(order.addressSnapshotJson) as AddressSnapshot
} catch {
return null
}
}, [order])
if (!id) return <Alert severity="error">Некорректный заказ.</Alert>
if (orderQuery.isLoading) return <Typography>Загрузка</Typography>
if (orderQuery.isError || !order) return <Alert severity="error">Не удалось загрузить заказ.</Alert>
return (
<Box>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} alignItems={{ sm: 'center' }} sx={{ mb: 2 }}>
<Box sx={{ flexGrow: 1 }}>
<Typography variant="h4">Заказ #{order.id.slice(-6)}</Typography>
<Typography color="text.secondary">Статус: {order.status}</Typography>
</Box>
<Button component={RouterLink} to="/me/orders" variant="outlined">
К списку
</Button>
</Stack>
<Stack spacing={2} sx={{ maxWidth: 900 }}>
<Box sx={{ border: 1, borderColor: 'divider', borderRadius: 2, p: 2, bgcolor: 'background.paper' }}>
<Typography variant="h6" gutterBottom>
Позиции
</Typography>
<Stack spacing={1}>
{order.items.map((i) => (
<Stack key={i.id} direction="row" spacing={2} alignItems="center">
<Box sx={{ flexGrow: 1 }}>
<Typography sx={{ fontWeight: 700 }}>{i.titleSnapshot}</Typography>
<Typography color="text.secondary" variant="body2">
{i.qty} × {formatPriceRub(i.priceCentsSnapshot)}
</Typography>
</Box>
<Typography sx={{ fontWeight: 700 }}>{formatPriceRub(i.priceCentsSnapshot * i.qty)}</Typography>
</Stack>
))}
</Stack>
<Divider sx={{ my: 2 }} />
<Typography variant="h6">Итого: {formatPriceRub(order.totalCents)}</Typography>
</Box>
<Box sx={{ border: 1, borderColor: 'divider', borderRadius: 2, p: 2, bgcolor: 'background.paper' }}>
<Typography variant="h6" gutterBottom>
Доставка
</Typography>
{address ? (
<>
<Typography sx={{ fontWeight: 700 }}>{address.addressLine}</Typography>
<Typography color="text.secondary" variant="body2">
Получатель: {address.recipientName} · {address.recipientPhone}
</Typography>
{address.comment && (
<Typography color="text.secondary" variant="body2">
Комментарий: {address.comment}
</Typography>
)}
</>
) : (
<Typography color="text.secondary">Адрес не распознан.</Typography>
)}
{order.comment && (
<Typography color="text.secondary" variant="body2" sx={{ mt: 1 }}>
Комментарий к заказу: {order.comment}
</Typography>
)}
</Box>
<Box sx={{ border: 1, borderColor: 'divider', borderRadius: 2, p: 2, bgcolor: 'background.paper' }}>
<Typography variant="h6" gutterBottom>
Оплата
</Typography>
<Typography color="text.secondary" variant="body2" sx={{ mb: 1 }}>
Пока это заглушка. Позже подключим реальную оплату.
</Typography>
<Button variant="contained" onClick={() => payMut.mutate()} disabled={payMut.isPending}>
Оплатить (заглушка)
</Button>
</Box>
<Box sx={{ border: 1, borderColor: 'divider', borderRadius: 2, p: 2, bgcolor: 'background.paper' }}>
<Typography variant="h6" gutterBottom>
Чат по заказу
</Typography>
<Stack spacing={1} sx={{ mb: 2 }}>
{order.messages.map((m) => (
<Box
key={m.id}
sx={{
p: 1.25,
borderRadius: 2,
bgcolor: m.authorType === 'admin' ? 'grey.100' : 'primary.50',
border: 1,
borderColor: 'divider',
}}
>
<Typography variant="caption" color="text.secondary">
{m.authorType === 'admin' ? 'Админ' : 'Вы'} · {new Date(m.createdAt).toLocaleString()}
</Typography>
<Typography sx={{ whiteSpace: 'pre-wrap' }}>{m.text}</Typography>
</Box>
))}
{order.messages.length === 0 && <Typography color="text.secondary">Пока сообщений нет.</Typography>}
</Stack>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} alignItems={{ sm: 'flex-end' }}>
<TextField
label="Сообщение"
value={text}
onChange={(e) => setText(e.target.value)}
fullWidth
multiline
minRows={2}
/>
<Button
variant="contained"
onClick={() => msgMut.mutate()}
disabled={msgMut.isPending || !text.trim()}
sx={{ minWidth: 160 }}
>
Отправить
</Button>
</Stack>
</Box>
</Stack>
</Box>
)
}
+51 -1
View File
@@ -1,13 +1,63 @@
import Alert from '@mui/material/Alert'
import Box from '@mui/material/Box'
import Button from '@mui/material/Button'
import Stack from '@mui/material/Stack'
import Typography from '@mui/material/Typography'
import { useQuery } from '@tanstack/react-query'
import { Link as RouterLink } from 'react-router-dom'
import { fetchMyOrders } from '@/entities/order/api/order-api'
import { formatPriceRub } from '@/shared/lib/format-price'
export function OrdersPage() {
const ordersQuery = useQuery({
queryKey: ['me', 'orders'],
queryFn: fetchMyOrders,
})
const items = ordersQuery.data?.items ?? []
return (
<Box>
<Typography variant="h4" gutterBottom>
Заказы
</Typography>
<Typography color="text.secondary">Скоро здесь появится история заказов.</Typography>
{ordersQuery.isError && <Alert severity="error">Не удалось загрузить заказы.</Alert>}
{ordersQuery.isSuccess && items.length === 0 && (
<Alert severity="info">Заказов пока нет. Оформите заказ из корзины.</Alert>
)}
<Stack spacing={2}>
{items.map((o) => (
<Box
key={o.id}
sx={{
border: 1,
borderColor: 'divider',
borderRadius: 2,
p: 2,
bgcolor: 'background.paper',
}}
>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={1} alignItems={{ sm: 'center' }}>
<Box sx={{ flexGrow: 1 }}>
<Typography sx={{ fontWeight: 700 }}>Заказ #{o.id.slice(-6)}</Typography>
<Typography color="text.secondary" variant="body2">
Статус: {o.status} · {o.itemsCount} поз.
</Typography>
</Box>
<Typography sx={{ fontWeight: 700 }}>{formatPriceRub(o.totalCents)}</Typography>
</Stack>
<Stack direction="row" spacing={1} sx={{ mt: 1.5 }}>
<Button component={RouterLink} to={`/me/orders/${o.id}`} size="small" variant="outlined">
Открыть
</Button>
</Stack>
</Box>
))}
</Stack>
</Box>
)
}
+25 -1
View File
@@ -2,22 +2,28 @@ import { useMemo, useState } from 'react'
import CloseIcon from '@mui/icons-material/Close'
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 IconButton from '@mui/material/IconButton'
import Skeleton from '@mui/material/Skeleton'
import Typography from '@mui/material/Typography'
import { useQuery } from '@tanstack/react-query'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useParams } from 'react-router-dom'
import { Swiper, SwiperSlide } from 'swiper/react'
import { Navigation } from 'swiper/modules'
import 'swiper/css'
import 'swiper/css/navigation'
import { useUnit } from 'effector-react'
import { addToCart } from '@/entities/cart/api/cart-api'
import { fetchPublicProduct } from '@/entities/product/api/product-api'
import { formatPriceRub } from '@/shared/lib/format-price'
import { $user } from '@/shared/model/auth'
export function ProductPage() {
const { id } = useParams()
const qc = useQueryClient()
const user = useUnit($user)
const [viewerOpen, setViewerOpen] = useState(false)
const [viewerIndex, setViewerIndex] = useState(0)
@@ -27,6 +33,15 @@ export function ProductPage() {
enabled: Boolean(id),
})
const addMut = useMutation({
mutationFn: async () => {
const pid = productQuery.data?.id
if (!pid) throw new Error('Товар ещё не загружен')
await addToCart({ productId: pid, qty: 1 })
},
onSuccess: () => void qc.invalidateQueries({ queryKey: ['me', 'cart'] }),
})
const imageUrls = useMemo(() => {
const p = productQuery.data
if (!p) return []
@@ -136,6 +151,15 @@ export function ProductPage() {
{formatPriceRub(p.priceCents)}
</Typography>
<Button
variant="contained"
disabled={!user || addMut.isPending || !p}
onClick={() => addMut.mutate()}
sx={{ alignSelf: 'flex-start' }}
>
{user ? 'В корзину' : 'Войдите, чтобы купить'}
</Button>
{p.description ? (
<Typography sx={{ whiteSpace: 'pre-wrap' }}>{p.description}</Typography>
) : (