base commit

This commit is contained in:
@kirill.komarov
2026-05-10 13:50:44 +05:00
parent 6c07488964
commit 97537a8717
22 changed files with 307 additions and 100 deletions
+2
View File
@@ -1,6 +1,7 @@
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom' import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'
import { MainLayout } from '@/app/layout/MainLayout' import { MainLayout } from '@/app/layout/MainLayout'
import { AppProviders } from '@/app/providers/AppProviders' import { AppProviders } from '@/app/providers/AppProviders'
import { AboutPage } from '@/pages/about'
import { AdminLayoutPage } from '@/pages/admin-layout' import { AdminLayoutPage } from '@/pages/admin-layout'
import { AuthCallbackPage, AuthPage } from '@/pages/auth' import { AuthCallbackPage, AuthPage } from '@/pages/auth'
import { CartPage } from '@/pages/cart' import { CartPage } from '@/pages/cart'
@@ -22,6 +23,7 @@ export function App() {
<Route path="/auth/callback" element={<AuthCallbackPage />} /> <Route path="/auth/callback" element={<AuthCallbackPage />} />
<Route path="/cart" element={<CartPage />} /> <Route path="/cart" element={<CartPage />} />
<Route path="/checkout" element={<CheckoutPage />} /> <Route path="/checkout" element={<CheckoutPage />} />
<Route path="/about" element={<AboutPage />} />
<Route path="/info" element={<InfoPage />} /> <Route path="/info" element={<InfoPage />} />
<Route path="/me/*" element={<MeLayoutPage />} /> <Route path="/me/*" element={<MeLayoutPage />} />
<Route path="/products/:id" element={<ProductPage />} /> <Route path="/products/:id" element={<ProductPage />} />
+1
View File
@@ -39,6 +39,7 @@ type NavItem = { label: string; to: string }
const navItems: NavItem[] = [ const navItems: NavItem[] = [
{ label: 'Каталог', to: '/' }, { label: 'Каталог', to: '/' },
{ label: 'О нас', to: '/about' },
{ label: 'О покупке', to: '/info' }, { label: 'О покупке', to: '/info' },
] ]
+11 -4
View File
@@ -34,7 +34,7 @@ export function MainLayout({ children }: PropsWithChildren) {
<Container maxWidth="lg"> <Container maxWidth="lg">
<Grid container spacing={3}> <Grid container spacing={3}>
<Grid size={{ xs: 12, sm: 4 }}> <Grid size={{ xs: 12, sm: 4 }}>
<Typography variant="subtitle1" fontWeight={700} gutterBottom> <Typography variant="subtitle1" gutterBottom sx={{ fontWeight: 700 }}>
Магазин Магазин
</Typography> </Typography>
<Stack spacing={1}> <Stack spacing={1}>
@@ -50,10 +50,13 @@ export function MainLayout({ children }: PropsWithChildren) {
</Stack> </Stack>
</Grid> </Grid>
<Grid size={{ xs: 12, sm: 4 }}> <Grid size={{ xs: 12, sm: 4 }}>
<Typography variant="subtitle1" fontWeight={700} gutterBottom> <Typography variant="subtitle1" gutterBottom sx={{ fontWeight: 700 }}>
Покупателям Покупателям
</Typography> </Typography>
<Stack spacing={1}> <Stack spacing={1}>
<Link component={RouterLink} to="/about" color="inherit" underline="hover" variant="body2">
О нас и самовывоз
</Link>
<Link component={RouterLink} to="/me" color="inherit" underline="hover" variant="body2"> <Link component={RouterLink} to="/me" color="inherit" underline="hover" variant="body2">
Личный кабинет Личный кабинет
</Link> </Link>
@@ -63,7 +66,7 @@ export function MainLayout({ children }: PropsWithChildren) {
</Stack> </Stack>
</Grid> </Grid>
<Grid size={{ xs: 12, sm: 4 }}> <Grid size={{ xs: 12, sm: 4 }}>
<Typography variant="subtitle1" fontWeight={700} gutterBottom> <Typography variant="subtitle1" gutterBottom sx={{ fontWeight: 700 }}>
Контакты Контакты
</Typography> </Typography>
<Stack spacing={0.75}> <Stack spacing={0.75}>
@@ -86,7 +89,11 @@ export function MainLayout({ children }: PropsWithChildren) {
</Grid> </Grid>
</Grid> </Grid>
<Divider sx={{ my: 2 }} /> <Divider sx={{ my: 2 }} />
<Typography variant="caption" color="text.secondary" display="block" textAlign={{ xs: 'left', sm: 'center' }}> <Typography
variant="caption"
color="text.secondary"
sx={{ display: 'block', textAlign: { xs: 'left', sm: 'center' } }}
>
© {year} {STORE_NAME}. Сделано для демонстрации возможностей витрины. © {year} {STORE_NAME}. Сделано для демонстрации возможностей витрины.
</Typography> </Typography>
</Container> </Container>
@@ -4,6 +4,7 @@ export type AdminOrderListItem = {
id: string id: string
status: string status: string
deliveryType: 'delivery' | 'pickup' deliveryType: 'delivery' | 'pickup'
paymentMethod?: 'online' | 'on_pickup'
totalCents: number totalCents: number
currency: string currency: string
createdAt: string createdAt: string
@@ -24,6 +25,7 @@ export type AdminOrderDetailResponse = {
id: string id: string
status: string status: string
deliveryType: 'delivery' | 'pickup' deliveryType: 'delivery' | 'pickup'
paymentMethod?: 'online' | 'on_pickup'
itemsSubtotalCents: number itemsSubtotalCents: number
deliveryFeeCents: number deliveryFeeCents: number
totalCents: number totalCents: number
@@ -12,11 +12,14 @@ export type OrderListItem = {
export type OrderListResponse = { items: OrderListItem[] } export type OrderListResponse = { items: OrderListItem[] }
export type OrderPaymentMethod = 'online' | 'on_pickup'
export type OrderDetailResponse = { export type OrderDetailResponse = {
item: { item: {
id: string id: string
status: string status: string
deliveryType: 'delivery' | 'pickup' deliveryType: 'delivery' | 'pickup'
paymentMethod?: OrderPaymentMethod
itemsSubtotalCents: number itemsSubtotalCents: number
deliveryFeeCents: number deliveryFeeCents: number
totalCents: number totalCents: number
@@ -43,6 +46,7 @@ export type OrderDetailResponse = {
export async function createOrder(body: { export async function createOrder(body: {
deliveryType: 'delivery' | 'pickup' deliveryType: 'delivery' | 'pickup'
paymentMethod?: OrderPaymentMethod
addressId?: string | null addressId?: string | null
comment?: string | null comment?: string | null
}): Promise<{ orderId: string }> { }): Promise<{ orderId: string }> {
+1
View File
@@ -0,0 +1 @@
export { AboutPage } from './ui/AboutPage'
+87
View File
@@ -0,0 +1,87 @@
import Box from '@mui/material/Box'
import Paper from '@mui/material/Paper'
import Stack from '@mui/material/Stack'
import Typography from '@mui/material/Typography'
import * as maplibregl from 'maplibre-gl'
import Map, { Marker } from 'react-map-gl/maplibre'
import { PICKUP_ADDRESS_FULL, PICKUP_COORDINATES } from '@/shared/constants/pickup-point'
const rasterStyle = {
version: 8 as const,
sources: {
osm: {
type: 'raster' as const,
tiles: ['https://tile.openstreetmap.org/{z}/{x}/{y}.png'],
tileSize: 256,
attribution: '© OpenStreetMap contributors',
},
},
layers: [{ id: 'osm', type: 'raster' as const, source: 'osm' }],
}
export function AboutPage() {
const { lat, lng } = PICKUP_COORDINATES
return (
<Box>
<Typography variant="h4" gutterBottom>
О нас
</Typography>
<Typography color="text.secondary" sx={{ mb: 3 }}>
Магазин изделий ручной работы. Мы отвечаем за качество и сроки изготовления всего, что вы видите в каталоге.
</Typography>
<Stack spacing={3}>
<Paper variant="outlined" sx={{ p: 2, borderRadius: 2 }}>
<Typography variant="h6" gutterBottom>
Контакты и самовывоз
</Typography>
<Typography sx={{ mb: 1 }}>
Забрать заказ можно по адресу самовывоза (координаты указаны на карте ниже):
</Typography>
<Typography sx={{ whiteSpace: 'pre-wrap', fontWeight: 600 }}>{PICKUP_ADDRESS_FULL}</Typography>
<Typography color="text.secondary" variant="body2" sx={{ mt: 1 }}>
Перед визитом мы свяжемся с вами и согласуем время чтобы заказ точно был готов к выдаче.
</Typography>
</Paper>
<Box
sx={{
height: 380,
borderRadius: 2,
overflow: 'hidden',
border: 1,
borderColor: 'divider',
position: 'relative',
}}
>
<Map
mapLib={maplibregl}
initialViewState={{ latitude: lat, longitude: lng, zoom: 16 }}
style={{ width: '100%', height: 380 }}
mapStyle={rasterStyle}
scrollZoom={false}
dragRotate={false}
dragPan={false}
doubleClickZoom={false}
keyboard={false}
touchZoomRotate={false}
>
<Marker longitude={lng} latitude={lat} anchor="bottom">
<Box
sx={{
width: 20,
height: 20,
bgcolor: 'primary.main',
borderRadius: '50%',
border: 2,
borderColor: 'background.paper',
boxShadow: 3,
}}
/>
</Marker>
</Map>
</Box>
</Stack>
</Box>
)
}
@@ -30,6 +30,7 @@ import { formatPriceRub } from '@/shared/lib/format-price'
import { groupOrdersByStatus } from '@/shared/lib/group-orders-by-status' import { groupOrdersByStatus } from '@/shared/lib/group-orders-by-status'
import { invalidateQueryKeys } from '@/shared/lib/invalidate-query-keys' import { invalidateQueryKeys } from '@/shared/lib/invalidate-query-keys'
import { orderStatusLabelRu } from '@/shared/lib/order-status-labels' import { orderStatusLabelRu } from '@/shared/lib/order-status-labels'
import { ChatMessageBubble } from '@/shared/ui/ChatMessageBubble'
import { RichTextMessageContent } from '@/shared/ui/RichTextMessageContent' import { RichTextMessageContent } from '@/shared/ui/RichTextMessageContent'
import { RichTextMessageEditor } from '@/shared/ui/RichTextMessageEditor' import { RichTextMessageEditor } from '@/shared/ui/RichTextMessageEditor'
@@ -207,6 +208,10 @@ export function AdminOrdersPage() {
#{detail.id.slice(-8)} · {detail.user.email} · {orderStatusLabelRu(detail.status)} ·{' '} #{detail.id.slice(-8)} · {detail.user.email} · {orderStatusLabelRu(detail.status)} ·{' '}
{formatPriceRub(detail.totalCents)} {formatPriceRub(detail.totalCents)}
</Typography> </Typography>
<Typography variant="body2" color="text.secondary">
Получение: {detail.deliveryType === 'pickup' ? 'самовывоз' : 'доставка'}
{(detail.paymentMethod ?? 'online') === 'on_pickup' ? ' · оплата при получении' : ' · онлайн-оплата'}
</Typography>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} sx={{ alignItems: { sm: 'center' } }}> <Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} sx={{ alignItems: { sm: 'center' } }}>
<FormControl size="small" sx={{ minWidth: 240 }}> <FormControl size="small" sx={{ minWidth: 240 }}>
@@ -240,25 +245,13 @@ export function AdminOrdersPage() {
</Typography> </Typography>
<Stack spacing={1} sx={{ mb: 1 }}> <Stack spacing={1} sx={{ mb: 1 }}>
{detail.messages.map((m) => ( {detail.messages.map((m) => (
<Box <ChatMessageBubble key={m.id} authorType={m.authorType === 'admin' ? 'user' : 'admin'}>
key={m.id}
sx={{
p: 1.25,
border: 1,
borderColor: 'divider',
borderRadius: 2,
bgcolor: m.authorType === 'admin' ? 'primary.50' : 'grey.100',
alignSelf: m.authorType === 'admin' ? 'flex-end' : 'flex-start',
width: 'fit-content',
maxWidth: '85%',
}}
>
<Typography variant="caption" color="text.secondary"> <Typography variant="caption" color="text.secondary">
{m.authorType === 'admin' ? 'Админ (вы)' : 'Пользователь'} ·{' '} {m.authorType === 'admin' ? 'Админ (вы)' : 'Пользователь'} ·{' '}
{new Date(m.createdAt).toLocaleString()} {new Date(m.createdAt).toLocaleString()}
</Typography> </Typography>
<RichTextMessageContent value={m.text} tone="chat" /> <RichTextMessageContent value={m.text} tone="chat" />
</Box> </ChatMessageBubble>
))} ))}
{detail.messages.length === 0 && <Typography color="text.secondary">Нет сообщений.</Typography>} {detail.messages.length === 0 && <Typography color="text.secondary">Нет сообщений.</Typography>}
</Stack> </Stack>
+1 -15
View File
@@ -2,7 +2,6 @@ import { useEffect, useState } from 'react'
import Alert from '@mui/material/Alert' import Alert from '@mui/material/Alert'
import Box from '@mui/material/Box' import Box from '@mui/material/Box'
import Button from '@mui/material/Button' import Button from '@mui/material/Button'
import Divider from '@mui/material/Divider'
import Stack from '@mui/material/Stack' import Stack from '@mui/material/Stack'
import TextField from '@mui/material/TextField' import TextField from '@mui/material/TextField'
import Typography from '@mui/material/Typography' import Typography from '@mui/material/Typography'
@@ -11,7 +10,6 @@ import { useUnit } from 'effector-react'
import { useForm } from 'react-hook-form' import { useForm } from 'react-hook-form'
import { useNavigate, useSearchParams } from 'react-router-dom' import { useNavigate, useSearchParams } from 'react-router-dom'
import { apiClient } from '@/shared/api/client' import { apiClient } from '@/shared/api/client'
import { oauthAuthorizeUrl } from '@/shared/lib/oauth-authorize-url'
import { $user, tokenSet } from '@/shared/model/auth' import { $user, tokenSet } from '@/shared/model/auth'
type AuthResponse = { token: string; user: { id: string; email: string; name?: string | null; phone?: string | null } } type AuthResponse = { token: string; user: { id: string; email: string; name?: string | null; phone?: string | null } }
@@ -93,21 +91,9 @@ export function AuthPage() {
)} )}
<Stack spacing={2} sx={{ maxWidth: 520 }}> <Stack spacing={2} sx={{ maxWidth: 520 }}>
<Typography variant="subtitle1">Быстрый вход</Typography>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={1.5}>
<Button component="a" href={oauthAuthorizeUrl('vk')} variant="outlined" fullWidth>
Войти через VK
</Button>
<Button component="a" href={oauthAuthorizeUrl('yandex')} variant="outlined" fullWidth>
Войти через Яндекс
</Button>
</Stack>
<Divider>или по email</Divider>
<TextField label="Email" {...register('email')} fullWidth /> <TextField label="Email" {...register('email')} fullWidth />
<Typography variant="h6">Вариант 1: Email + код</Typography> <Typography variant="h6">Email + код</Typography>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2}> <Stack direction={{ xs: 'column', sm: 'row' }} spacing={2}>
<Button variant="outlined" onClick={() => requestCode.mutate()} disabled={!email || requestCode.isPending}> <Button variant="outlined" onClick={() => requestCode.mutate()} disabled={!email || requestCode.isPending}>
Отправить код Отправить код
+39 -2
View File
@@ -3,8 +3,12 @@ import Alert from '@mui/material/Alert'
import Box from '@mui/material/Box' import Box from '@mui/material/Box'
import Button from '@mui/material/Button' import Button from '@mui/material/Button'
import FormControl from '@mui/material/FormControl' import FormControl from '@mui/material/FormControl'
import FormControlLabel from '@mui/material/FormControlLabel'
import InputLabel from '@mui/material/InputLabel' import InputLabel from '@mui/material/InputLabel'
import Link from '@mui/material/Link'
import MenuItem from '@mui/material/MenuItem' 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 Select from '@mui/material/Select'
import Stack from '@mui/material/Stack' import Stack from '@mui/material/Stack'
import TextField from '@mui/material/TextField' import TextField from '@mui/material/TextField'
@@ -23,6 +27,7 @@ export function CheckoutPage() {
const qc = useQueryClient() const qc = useQueryClient()
const navigate = useNavigate() const navigate = useNavigate()
const [deliveryType, setDeliveryType] = useState<'delivery' | 'pickup'>('delivery') const [deliveryType, setDeliveryType] = useState<'delivery' | 'pickup'>('delivery')
const [pickupPayment, setPickupPayment] = useState<'online' | 'on_pickup'>('online')
const [addressId, setAddressId] = useState('') const [addressId, setAddressId] = useState('')
const [comment, setComment] = useState('') const [comment, setComment] = useState('')
@@ -45,6 +50,7 @@ export function CheckoutPage() {
mutationFn: () => mutationFn: () =>
createOrder({ createOrder({
deliveryType, deliveryType,
paymentMethod: deliveryType === 'delivery' ? 'online' : pickupPayment,
addressId: deliveryType === 'delivery' ? selectedAddressId : null, addressId: deliveryType === 'delivery' ? selectedAddressId : null,
comment: comment.trim() || null, comment: comment.trim() || null,
}), }),
@@ -134,7 +140,10 @@ export function CheckoutPage() {
value={deliveryType} value={deliveryType}
onChange={(e) => { onChange={(e) => {
const v = String(e.target.value) const v = String(e.target.value)
if (v === 'delivery' || v === 'pickup') setDeliveryType(v) if (v === 'delivery' || v === 'pickup') {
setDeliveryType(v)
if (v === 'delivery') setPickupPayment('online')
}
}} }}
> >
<MenuItem value="delivery">Доставка</MenuItem> <MenuItem value="delivery">Доставка</MenuItem>
@@ -183,7 +192,35 @@ export function CheckoutPage() {
)} )}
{deliveryType === 'pickup' && ( {deliveryType === 'pickup' && (
<Alert severity="info">Самовывоз: адрес доставки не нужен. Мы свяжемся с вами для согласования.</Alert> <>
<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 <TextField
+1 -6
View File
@@ -371,12 +371,7 @@ export function HomePage() {
product={p} product={p}
mediaHeight={mediaHeight} mediaHeight={mediaHeight}
actions={ actions={
!isAdmin ? ( !isAdmin && !(p.inStock && p.quantity === 0) ? <ToggleCartIcon productId={p.id} /> : undefined
<ToggleCartIcon
productId={p.id}
disabledReason={p.inStock && p.quantity === 0 ? 'Нет в наличии' : null}
/>
) : undefined
} }
/> />
</Grid> </Grid>
@@ -14,6 +14,7 @@ import { Link as RouterLink } from 'react-router-dom'
import { fetchMyOrder, postOrderMessage } from '@/entities/order/api/order-api' import { fetchMyOrder, postOrderMessage } from '@/entities/order/api/order-api'
import { fetchMyConversations, markOrderMessagesRead } from '@/entities/user/api/messages-api' import { fetchMyConversations, markOrderMessagesRead } from '@/entities/user/api/messages-api'
import { orderStatusLabelRu } from '@/shared/lib/order-status-labels' import { orderStatusLabelRu } from '@/shared/lib/order-status-labels'
import { ChatMessageBubble } from '@/shared/ui/ChatMessageBubble'
import { RichTextMessageContent } from '@/shared/ui/RichTextMessageContent' import { RichTextMessageContent } from '@/shared/ui/RichTextMessageContent'
import { RichTextMessageEditor } from '@/shared/ui/RichTextMessageEditor' import { RichTextMessageEditor } from '@/shared/ui/RichTextMessageEditor'
@@ -114,13 +115,15 @@ export function MessagesPage() {
</Typography> </Typography>
</Stack> </Stack>
} }
secondaryTypographyProps={{ slotProps={{
sx: { secondary: {
mt: 0.5, sx: {
overflow: 'hidden', mt: 0.5,
display: '-webkit-box', overflow: 'hidden',
WebkitLineClamp: 2, display: '-webkit-box',
WebkitBoxOrient: 'vertical', WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
},
}, },
}} }}
secondary={c.preview} secondary={c.preview}
@@ -160,24 +163,12 @@ export function MessagesPage() {
</Stack> </Stack>
<Stack spacing={1} sx={{ mb: 2, maxHeight: 360, overflow: 'auto' }}> <Stack spacing={1} sx={{ mb: 2, maxHeight: 360, overflow: 'auto' }}>
{order.messages.map((m) => ( {order.messages.map((m) => (
<Box <ChatMessageBubble key={m.id} authorType={m.authorType === 'admin' ? 'admin' : 'user'}>
key={m.id}
sx={{
p: 1.25,
borderRadius: 2,
bgcolor: m.authorType === 'admin' ? 'grey.100' : 'primary.50',
border: 1,
borderColor: 'divider',
alignSelf: m.authorType === 'admin' ? 'flex-start' : 'flex-end',
width: 'fit-content',
maxWidth: '85%',
}}
>
<Typography variant="caption" color="text.secondary"> <Typography variant="caption" color="text.secondary">
{m.authorType === 'admin' ? 'Админ' : 'Вы'} · {new Date(m.createdAt).toLocaleString()} {m.authorType === 'admin' ? 'Админ' : 'Вы'} · {new Date(m.createdAt).toLocaleString()}
</Typography> </Typography>
<RichTextMessageContent value={m.text} tone="chat" /> <RichTextMessageContent value={m.text} tone="chat" />
</Box> </ChatMessageBubble>
))} ))}
{order.messages.length === 0 && <Typography color="text.secondary">Нет сообщений.</Typography>} {order.messages.length === 0 && <Typography color="text.secondary">Нет сообщений.</Typography>}
</Stack> </Stack>
@@ -7,6 +7,7 @@ import DialogActions from '@mui/material/DialogActions'
import DialogContent from '@mui/material/DialogContent' import DialogContent from '@mui/material/DialogContent'
import DialogTitle from '@mui/material/DialogTitle' import DialogTitle from '@mui/material/DialogTitle'
import Divider from '@mui/material/Divider' import Divider from '@mui/material/Divider'
import Link from '@mui/material/Link'
import Rating from '@mui/material/Rating' import Rating from '@mui/material/Rating'
import Stack from '@mui/material/Stack' import Stack from '@mui/material/Stack'
import Typography from '@mui/material/Typography' import Typography from '@mui/material/Typography'
@@ -22,8 +23,10 @@ import {
} from '@/entities/order/api/order-api' } from '@/entities/order/api/order-api'
import { postProductReview, uploadReviewImage } from '@/entities/product/api/reviews-api' import { postProductReview, uploadReviewImage } from '@/entities/product/api/reviews-api'
import { markOrderMessagesRead } from '@/entities/user/api/messages-api' import { markOrderMessagesRead } from '@/entities/user/api/messages-api'
import { PICKUP_ADDRESS_FULL } from '@/shared/constants/pickup-point'
import { formatPriceRub } from '@/shared/lib/format-price' import { formatPriceRub } from '@/shared/lib/format-price'
import { orderStatusLabelRu } from '@/shared/lib/order-status-labels' import { orderStatusLabelRu } from '@/shared/lib/order-status-labels'
import { ChatMessageBubble } from '@/shared/ui/ChatMessageBubble'
import { RichTextMessageContent } from '@/shared/ui/RichTextMessageContent' import { RichTextMessageContent } from '@/shared/ui/RichTextMessageContent'
import { RichTextMessageEditor } from '@/shared/ui/RichTextMessageEditor' import { RichTextMessageEditor } from '@/shared/ui/RichTextMessageEditor'
@@ -96,6 +99,7 @@ export function OrderDetailPage() {
}) })
const order = orderQuery.data?.item const order = orderQuery.data?.item
const payOnPickup = (order?.paymentMethod ?? 'online') === 'on_pickup'
const canSendMessage = text.replace(/<[^>]*>/g, ' ').trim().length > 0 const canSendMessage = text.replace(/<[^>]*>/g, ' ').trim().length > 0
const eligibilityQuery = useQuery({ const eligibilityQuery = useQuery({
@@ -199,6 +203,7 @@ export function OrderDetailPage() {
</Typography> </Typography>
<Typography color="text.secondary" variant="body2" sx={{ mb: 1 }}> <Typography color="text.secondary" variant="body2" sx={{ mb: 1 }}>
Способ: {order.deliveryType === 'pickup' ? 'Самовывоз' : 'Доставка'} Способ: {order.deliveryType === 'pickup' ? 'Самовывоз' : 'Доставка'}
{order.deliveryType === 'pickup' && <> · оплата: {payOnPickup ? 'при получении' : 'онлайн'}</>}
</Typography> </Typography>
{order.deliveryType === 'delivery' && ( {order.deliveryType === 'delivery' && (
<> <>
@@ -220,9 +225,21 @@ export function OrderDetailPage() {
</> </>
)} )}
{order.deliveryType === 'pickup' && ( {order.deliveryType === 'pickup' && (
<Typography color="text.secondary"> <Stack spacing={0.75}>
Адрес доставки не требуется. Мы свяжемся для согласования самовывоза. <Typography color="text.secondary" variant="body2">
</Typography> Адрес самовывоза и карта на странице{' '}
<Link component={RouterLink} to="/about" underline="hover">
О нас
</Link>
.
</Typography>
<Typography variant="body2" sx={{ fontWeight: 600 }}>
{PICKUP_ADDRESS_FULL}
</Typography>
<Typography color="text.secondary" variant="body2">
Заберите заказ точно ко времени, которое согласуем по телефону или в чате заказа.
</Typography>
</Stack>
)} )}
{order.comment && ( {order.comment && (
<Typography color="text.secondary" variant="body2" sx={{ mt: 1 }}> <Typography color="text.secondary" variant="body2" sx={{ mt: 1 }}>
@@ -235,25 +252,33 @@ export function OrderDetailPage() {
<Typography variant="h6" gutterBottom> <Typography variant="h6" gutterBottom>
Оплата Оплата
</Typography> </Typography>
{order.status === 'PENDING_PAYMENT' && ( {payOnPickup ? (
<>
<Typography color="text.secondary" variant="body2" sx={{ mb: 1 }}>
Пока это заглушка. После нажатия заказ перейдёт в статус «Проверка оплаты».
</Typography>
<Button variant="contained" onClick={() => payMut.mutate()} disabled={payMut.isPending}>
Оплатить (заглушка)
</Button>
</>
)}
{order.status === 'PAYMENT_VERIFICATION' && (
<Typography color="info.main" variant="body2">
Оплата отправлена на проверку. Мы проверим поступление и обновим статус.
</Typography>
)}
{!['PENDING_PAYMENT', 'PAYMENT_VERIFICATION'].includes(order.status) && (
<Typography color="text.secondary" variant="body2"> <Typography color="text.secondary" variant="body2">
На этом этапе действий по оплате в этом блоке не требуется. Оплата при получении на точке самовывоза (наличные или карта по договорённости).
</Typography> </Typography>
) : (
<>
{order.status === 'PENDING_PAYMENT' && (
<>
<Typography color="text.secondary" variant="body2" sx={{ mb: 1 }}>
Пока это заглушка. После нажатия заказ перейдёт в статус «Проверка оплаты».
</Typography>
<Button variant="contained" onClick={() => payMut.mutate()} disabled={payMut.isPending}>
Оплатить (заглушка)
</Button>
</>
)}
{order.status === 'PAYMENT_VERIFICATION' && (
<Typography color="info.main" variant="body2">
Оплата отправлена на проверку. Мы проверим поступление и обновим статус.
</Typography>
)}
{!['PENDING_PAYMENT', 'PAYMENT_VERIFICATION'].includes(order.status) && (
<Typography color="text.secondary" variant="body2">
На этом этапе действий по оплате в этом блоке не требуется.
</Typography>
)}
</>
)} )}
</Box> </Box>
@@ -316,24 +341,12 @@ export function OrderDetailPage() {
</Typography> </Typography>
<Stack spacing={1} sx={{ mb: 2 }}> <Stack spacing={1} sx={{ mb: 2 }}>
{order.messages.map((m) => ( {order.messages.map((m) => (
<Box <ChatMessageBubble key={m.id} authorType={m.authorType === 'admin' ? 'admin' : 'user'}>
key={m.id}
sx={{
p: 1.25,
borderRadius: 2,
bgcolor: m.authorType === 'admin' ? 'grey.100' : 'primary.50',
border: 1,
borderColor: 'divider',
alignSelf: m.authorType === 'admin' ? 'flex-start' : 'flex-end',
width: 'fit-content',
maxWidth: '85%',
}}
>
<Typography variant="caption" color="text.secondary"> <Typography variant="caption" color="text.secondary">
{m.authorType === 'admin' ? 'Админ' : 'Вы'} · {new Date(m.createdAt).toLocaleString()} {m.authorType === 'admin' ? 'Админ' : 'Вы'} · {new Date(m.createdAt).toLocaleString()}
</Typography> </Typography>
<RichTextMessageContent value={m.text} tone="chat" /> <RichTextMessageContent value={m.text} tone="chat" />
</Box> </ChatMessageBubble>
))} ))}
{order.messages.length === 0 && <Typography color="text.secondary">Пока сообщений нет.</Typography>} {order.messages.length === 0 && <Typography color="text.secondary">Пока сообщений нет.</Typography>}
</Stack> </Stack>
+1 -1
View File
@@ -155,7 +155,7 @@ export function ProductPage() {
{formatPriceRub(p.priceCents)} {formatPriceRub(p.priceCents)}
</Typography> </Typography>
{!isAdmin && <ToggleCartIcon productId={p.id} size="medium" />} {!isAdmin && !(p.inStock && p.quantity === 0) ? <ToggleCartIcon productId={p.id} size="medium" /> : null}
{!p.inStock && ( {!p.inStock && (
<Alert severity="info"> <Alert severity="info">
@@ -0,0 +1,6 @@
/** Точка самовывоза (координаты центра участка ул. Мира по данным OSM/Nominatim). */
export const PICKUP_COORDINATES = { lat: 58.0994284, lng: 57.803296 }
/** Полная строка адреса для текстовых блоков. */
export const PICKUP_ADDRESS_FULL =
'34, улица Мира, Лысьва, Лысьвенский муниципальный округ, Пермский край, Приволжский федеральный округ, 618909, Россия'
@@ -0,0 +1,29 @@
import type { ReactNode } from 'react'
import Box from '@mui/material/Box'
import { alpha } from '@mui/material/styles'
type Author = 'admin' | 'user'
export function ChatMessageBubble(props: { authorType: Author; children: ReactNode }) {
const { authorType, children } = props
return (
<Box
sx={{
p: 1.25,
borderRadius: 2,
border: 1,
borderColor: 'divider',
alignSelf: authorType === 'admin' ? 'flex-start' : 'flex-end',
width: 'fit-content',
maxWidth: '85%',
color: 'text.primary',
bgcolor: (theme) =>
authorType === 'admin'
? alpha(theme.palette.grey[500], theme.palette.mode === 'dark' ? 0.28 : 0.14)
: alpha(theme.palette.primary.main, theme.palette.mode === 'dark' ? 0.28 : 0.1),
}}
>
{children}
</Box>
)
}
@@ -21,16 +21,18 @@ export function RichTextMessageContent({ value, tone = 'default' }: RichTextMess
if (!editor) return if (!editor) return
const normalizedValue = value.trim() ? value : '<p></p>' const normalizedValue = value.trim() ? value : '<p></p>'
if (editor.getHTML() === normalizedValue) return if (editor.getHTML() === normalizedValue) return
editor.commands.setContent(normalizedValue, false) editor.commands.setContent(normalizedValue, { emitUpdate: false })
}, [editor, value]) }, [editor, value])
return ( return (
<Box <Box
sx={{ sx={{
...(tone === 'chat' ? { color: 'text.primary' } : {}),
'& .ProseMirror': { '& .ProseMirror': {
outline: 'none', outline: 'none',
whiteSpace: 'pre-wrap', whiteSpace: 'pre-wrap',
wordBreak: 'break-word', wordBreak: 'break-word',
...(tone === 'chat' ? { color: 'inherit' } : {}),
...(tone === 'review' ...(tone === 'review'
? { ? {
fontSize: '0.875rem', fontSize: '0.875rem',
@@ -46,7 +46,7 @@ export function RichTextMessageEditor({
if (!editor) return if (!editor) return
const normalizedValue = value.trim() ? value : '<p></p>' const normalizedValue = value.trim() ? value : '<p></p>'
if (editor.getHTML() === normalizedValue) return if (editor.getHTML() === normalizedValue) return
editor.commands.setContent(normalizedValue, false) editor.commands.setContent(normalizedValue, { emitUpdate: false })
}, [editor, value]) }, [editor, value])
return ( return (
@@ -0,0 +1,26 @@
-- RedefineTables
PRAGMA defer_foreign_keys=ON;
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_Order" (
"id" TEXT NOT NULL PRIMARY KEY,
"status" TEXT NOT NULL DEFAULT 'DRAFT',
"deliveryType" TEXT NOT NULL DEFAULT 'delivery',
"paymentMethod" TEXT NOT NULL DEFAULT 'online',
"itemsSubtotalCents" INTEGER NOT NULL DEFAULT 0,
"deliveryFeeCents" INTEGER NOT NULL DEFAULT 0,
"totalCents" INTEGER NOT NULL DEFAULT 0,
"currency" TEXT NOT NULL DEFAULT 'RUB',
"addressSnapshotJson" TEXT,
"comment" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
"userId" TEXT NOT NULL,
CONSTRAINT "Order_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
INSERT INTO "new_Order" ("addressSnapshotJson", "comment", "createdAt", "currency", "deliveryFeeCents", "deliveryType", "id", "itemsSubtotalCents", "status", "totalCents", "updatedAt", "userId") SELECT "addressSnapshotJson", "comment", "createdAt", "currency", "deliveryFeeCents", "deliveryType", "id", "itemsSubtotalCents", "status", "totalCents", "updatedAt", "userId" FROM "Order";
DROP TABLE "Order";
ALTER TABLE "new_Order" RENAME TO "Order";
CREATE INDEX "Order_userId_createdAt_idx" ON "Order"("userId", "createdAt");
CREATE INDEX "Order_status_updatedAt_idx" ON "Order"("status", "updatedAt");
PRAGMA foreign_keys=ON;
PRAGMA defer_foreign_keys=OFF;
+2
View File
@@ -109,6 +109,8 @@ model Order {
status String @default("DRAFT") status String @default("DRAFT")
/// 'delivery' | 'pickup' /// 'delivery' | 'pickup'
deliveryType String @default("delivery") deliveryType String @default("delivery")
/// 'online' | 'on_pickup' — способ расчёта для заказа
paymentMethod String @default("online")
itemsSubtotalCents Int @default(0) itemsSubtotalCents Int @default(0)
deliveryFeeCents Int @default(0) deliveryFeeCents Int @default(0)
totalCents Int @default(0) totalCents Int @default(0)
+1
View File
@@ -57,6 +57,7 @@ export async function registerAdminOrderRoutes(fastify) {
id: o.id, id: o.id,
status: o.status, status: o.status,
deliveryType: o.deliveryType, deliveryType: o.deliveryType,
paymentMethod: o.paymentMethod,
totalCents: o.totalCents, totalCents: o.totalCents,
currency: o.currency, currency: o.currency,
createdAt: o.createdAt, createdAt: o.createdAt,
+23 -1
View File
@@ -420,10 +420,23 @@ export async function registerAuthRoutes(fastify) {
const commentRaw = request.body?.comment const commentRaw = request.body?.comment
const comment = commentRaw === null || commentRaw === undefined ? null : String(commentRaw).trim() const comment = commentRaw === null || commentRaw === undefined ? null : String(commentRaw).trim()
const paymentMethodRaw = request.body?.paymentMethod
const paymentMethod =
paymentMethodRaw === undefined || paymentMethodRaw === null || paymentMethodRaw === ''
? 'online'
: String(paymentMethodRaw).trim()
if (paymentMethod !== 'online' && paymentMethod !== 'on_pickup') {
return reply.code(400).send({ error: 'paymentMethod должен быть online | on_pickup' })
}
if (deliveryType !== 'delivery' && deliveryType !== 'pickup') { if (deliveryType !== 'delivery' && deliveryType !== 'pickup') {
return reply.code(400).send({ error: 'deliveryType должен быть delivery | pickup' }) return reply.code(400).send({ error: 'deliveryType должен быть delivery | pickup' })
} }
if (paymentMethod === 'on_pickup' && deliveryType !== 'pickup') {
return reply.code(400).send({ error: 'Оплата при получении доступна только для самовывоза' })
}
let address = null let address = null
if (deliveryType === 'delivery') { if (deliveryType === 'delivery') {
if (!addressId) return reply.code(400).send({ error: 'Выберите адрес доставки' }) if (!addressId) return reply.code(400).send({ error: 'Выберите адрес доставки' })
@@ -472,6 +485,8 @@ export async function registerAuthRoutes(fastify) {
lng: address.lng, lng: address.lng,
}) })
const initialStatus = paymentMethod === 'on_pickup' ? 'IN_PROGRESS' : 'PENDING_PAYMENT'
let created let created
try { try {
created = await prisma.$transaction(async (tx) => { created = await prisma.$transaction(async (tx) => {
@@ -491,8 +506,9 @@ export async function registerAuthRoutes(fastify) {
const order = await tx.order.create({ const order = await tx.order.create({
data: { data: {
userId, userId,
status: 'PENDING_PAYMENT', status: initialStatus,
deliveryType, deliveryType,
paymentMethod,
itemsSubtotalCents, itemsSubtotalCents,
deliveryFeeCents, deliveryFeeCents,
totalCents, totalCents,
@@ -678,6 +694,12 @@ export async function registerAuthRoutes(fastify) {
const { id } = request.params const { id } = request.params
const order = await prisma.order.findFirst({ where: { id, userId } }) const order = await prisma.order.findFirst({ where: { id, userId } })
if (!order) return reply.code(404).send({ error: 'Заказ не найден' }) if (!order) return reply.code(404).send({ error: 'Заказ не найден' })
const paymentMethod = order.paymentMethod ?? 'online'
if (paymentMethod === 'on_pickup') {
return reply.code(409).send({ error: 'Для этого заказа оплата при получении — кнопка оплаты не нужна.' })
}
let nextStatus = order.status let nextStatus = order.status
if (order.status === 'DRAFT') { if (order.status === 'DRAFT') {
await prisma.order.update({ where: { id }, data: { status: 'PENDING_PAYMENT' } }) await prisma.order.update({ where: { id }, data: { status: 'PENDING_PAYMENT' } })