Files
shop-server/client/src/pages/checkout/ui/CheckoutPage.tsx
T
2026-05-15 12:50:39 +05:00

271 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState } from 'react'
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 FormControlLabel from '@mui/material/FormControlLabel'
import InputLabel from '@mui/material/InputLabel'
import Link from '@mui/material/Link'
import MenuItem from '@mui/material/MenuItem'
import Radio from '@mui/material/Radio'
import RadioGroup from '@mui/material/RadioGroup'
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 { useUnit } from 'effector-react'
import { Link as RouterLink, useNavigate } from 'react-router-dom'
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 { DELIVERY_CARRIER_OPTIONS, type DeliveryCarrierCode } from '@/shared/constants/delivery-carrier'
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 [deliveryType, setDeliveryType] = useState<'delivery' | 'pickup'>('delivery')
const [pickupPayment, setPickupPayment] = useState<'online' | 'on_pickup'>('online')
const [deliveryCarrier, setDeliveryCarrier] = useState<DeliveryCarrierCode>('RUSSIAN_POST')
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 defaultAddressId = addressesQuery.data?.items?.find((a) => a.isDefault)?.id ?? ''
const selectedAddressId = addressId || defaultAddressId
const createMut = useMutation({
mutationFn: () =>
createOrder({
deliveryType,
deliveryCarrier: deliveryType === 'delivery' ? deliveryCarrier : null,
paymentMethod: deliveryType === 'delivery' ? 'online' : pickupPayment,
addressId: deliveryType === 'delivery' ? selectedAddressId : null,
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 itemsSubtotalCents = items.reduce((s, x) => s + x.product.priceCents * x.qty, 0)
const deliveryFeeCents = deliveryType === 'delivery' && items.length > 0 ? 50000 : 0
const total = itemsSubtotalCents + deliveryFeeCents
const addresses = addressesQuery.data?.items ?? []
const hasOverLimit = items.some((x) => x.qty > x.product.quantity)
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 }}>
{items.length > 0 && (
<Box sx={{ border: 1, borderColor: 'divider', borderRadius: 2, p: 2, bgcolor: 'background.paper' }}>
<Typography sx={{ fontWeight: 700, mb: 1 }}>Позиции</Typography>
<Stack spacing={0.5}>
{items.map((x) => {
const available = x.product.quantity
const over = x.qty > available
return (
<Typography key={x.id} color={over ? 'error' : 'text.primary'}>
{x.product.title}: {x.qty} шт. (доступно {available})
</Typography>
)
})}
</Stack>
</Box>
)}
{hasOverLimit && (
<Alert severity="warning">
Некоторые позиции превышают доступное количество. Вернитесь в{' '}
<Typography component={RouterLink} to="/cart" sx={{ textDecoration: 'underline' }}>
корзину
</Typography>{' '}
и скорректируйте количество.
</Alert>
)}
<FormControl size="small" fullWidth>
<InputLabel id="delivery-type-label">Способ получения</InputLabel>
<Select
labelId="delivery-type-label"
label="Способ получения"
value={deliveryType}
onChange={(e) => {
const v = String(e.target.value)
if (v === 'delivery' || v === 'pickup') {
setDeliveryType(v)
if (v === 'delivery') setPickupPayment('online')
}
}}
>
<MenuItem value="delivery">Доставка</MenuItem>
<MenuItem value="pickup">Самовывоз</MenuItem>
</Select>
</FormControl>
{deliveryType === 'delivery' && (
<>
<Box>
<Typography variant="subtitle2" gutterBottom sx={{ mt: 0.5 }}>
Служба доставки
</Typography>
<RadioGroup
value={deliveryCarrier}
onChange={(e) => {
const v = String(e.target.value) as DeliveryCarrierCode
setDeliveryCarrier(v)
}}
>
{DELIVERY_CARRIER_OPTIONS.map((o) => (
<FormControlLabel key={o.code} value={o.code} control={<Radio size="small" />} label={o.label} />
))}
</RadioGroup>
</Box>
<FormControl size="small" fullWidth>
<InputLabel id="addr-label">Адрес доставки</InputLabel>
<Select
labelId="addr-label"
label="Адрес доставки"
value={selectedAddressId}
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>
)}
<Alert severity="info">
Стоимость доставки ориентировочно 300 . Точная цена будет скорректирована после расчёта. В сумме заказа
сейчас заложено {items.length > 0 ? formatPriceRub(deliveryFeeCents) : '500 ₽'} до уточнения.
</Alert>
</>
)}
{deliveryType === 'pickup' && (
<>
<Alert severity="info" sx={{ mb: 0 }}>
Самовывоз: адрес доставки не нужен. Адрес точки выдачи указан на странице{' '}
<Link component={RouterLink} to="/about" underline="hover" sx={{ fontWeight: 700 }}>
О нас
</Link>
.
</Alert>
<Box>
<Typography variant="subtitle2" gutterBottom sx={{ mt: 0.5 }}>
Оплата
</Typography>
<RadioGroup
value={pickupPayment}
onChange={(e) => {
const v = String(e.target.value)
if (v === 'online' || v === 'on_pickup') setPickupPayment(v)
}}
>
<FormControlLabel value="online" control={<Radio size="small" />} label="Онлайн-оплата" />
<FormControlLabel value="on_pickup" control={<Radio size="small" />} label="Оплатить при получении" />
</RadioGroup>
{pickupPayment === 'on_pickup' && (
<Alert severity="info" sx={{ mt: 1 }}>
Оплатите заказ наличными или картой при выдаче на самовывозе.
</Alert>
)}
</Box>
</>
)}
<TextField
label="Комментарий к заказу (необязательно)"
value={comment}
onChange={(e) => setComment(e.target.value)}
fullWidth
multiline
minRows={2}
/>
<Stack spacing={0.25}>
<Typography variant="body2" color="text.secondary">
Товары: {formatPriceRub(itemsSubtotalCents)}
</Typography>
{deliveryType === 'delivery' && (
<Typography variant="body2" color="text.secondary">
Доставка: {formatPriceRub(deliveryFeeCents)}
</Typography>
)}
<Typography variant="h6">Итого: {formatPriceRub(total)}</Typography>
</Stack>
<Button
variant="contained"
disabled={
items.length === 0 ||
hasOverLimit ||
createMut.isPending ||
(deliveryType === 'delivery' && (addresses.length === 0 || !selectedAddressId))
}
onClick={() => createMut.mutate()}
>
Создать заказ
</Button>
{createMut.isError && <Alert severity="error">{(createMut.error as Error).message}</Alert>}
</Stack>
</Box>
)
}