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 { MainLayout } from '@/app/layout/MainLayout'
import { AppProviders } from '@/app/providers/AppProviders'
import { AboutPage } from '@/pages/about'
import { AdminLayoutPage } from '@/pages/admin-layout'
import { AuthCallbackPage, AuthPage } from '@/pages/auth'
import { CartPage } from '@/pages/cart'
@@ -22,6 +23,7 @@ export function App() {
<Route path="/auth/callback" element={<AuthCallbackPage />} />
<Route path="/cart" element={<CartPage />} />
<Route path="/checkout" element={<CheckoutPage />} />
<Route path="/about" element={<AboutPage />} />
<Route path="/info" element={<InfoPage />} />
<Route path="/me/*" element={<MeLayoutPage />} />
<Route path="/products/:id" element={<ProductPage />} />
+1
View File
@@ -39,6 +39,7 @@ type NavItem = { label: string; to: string }
const navItems: NavItem[] = [
{ label: 'Каталог', to: '/' },
{ label: 'О нас', to: '/about' },
{ label: 'О покупке', to: '/info' },
]
+11 -4
View File
@@ -34,7 +34,7 @@ export function MainLayout({ children }: PropsWithChildren) {
<Container maxWidth="lg">
<Grid container spacing={3}>
<Grid size={{ xs: 12, sm: 4 }}>
<Typography variant="subtitle1" fontWeight={700} gutterBottom>
<Typography variant="subtitle1" gutterBottom sx={{ fontWeight: 700 }}>
Магазин
</Typography>
<Stack spacing={1}>
@@ -50,10 +50,13 @@ export function MainLayout({ children }: PropsWithChildren) {
</Stack>
</Grid>
<Grid size={{ xs: 12, sm: 4 }}>
<Typography variant="subtitle1" fontWeight={700} gutterBottom>
<Typography variant="subtitle1" gutterBottom sx={{ fontWeight: 700 }}>
Покупателям
</Typography>
<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>
@@ -63,7 +66,7 @@ export function MainLayout({ children }: PropsWithChildren) {
</Stack>
</Grid>
<Grid size={{ xs: 12, sm: 4 }}>
<Typography variant="subtitle1" fontWeight={700} gutterBottom>
<Typography variant="subtitle1" gutterBottom sx={{ fontWeight: 700 }}>
Контакты
</Typography>
<Stack spacing={0.75}>
@@ -86,7 +89,11 @@ export function MainLayout({ children }: PropsWithChildren) {
</Grid>
</Grid>
<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}. Сделано для демонстрации возможностей витрины.
</Typography>
</Container>
@@ -4,6 +4,7 @@ export type AdminOrderListItem = {
id: string
status: string
deliveryType: 'delivery' | 'pickup'
paymentMethod?: 'online' | 'on_pickup'
totalCents: number
currency: string
createdAt: string
@@ -24,6 +25,7 @@ export type AdminOrderDetailResponse = {
id: string
status: string
deliveryType: 'delivery' | 'pickup'
paymentMethod?: 'online' | 'on_pickup'
itemsSubtotalCents: number
deliveryFeeCents: number
totalCents: number
@@ -12,11 +12,14 @@ export type OrderListItem = {
export type OrderListResponse = { items: OrderListItem[] }
export type OrderPaymentMethod = 'online' | 'on_pickup'
export type OrderDetailResponse = {
item: {
id: string
status: string
deliveryType: 'delivery' | 'pickup'
paymentMethod?: OrderPaymentMethod
itemsSubtotalCents: number
deliveryFeeCents: number
totalCents: number
@@ -43,6 +46,7 @@ export type OrderDetailResponse = {
export async function createOrder(body: {
deliveryType: 'delivery' | 'pickup'
paymentMethod?: OrderPaymentMethod
addressId?: string | null
comment?: string | null
}): 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 { invalidateQueryKeys } from '@/shared/lib/invalidate-query-keys'
import { orderStatusLabelRu } from '@/shared/lib/order-status-labels'
import { ChatMessageBubble } from '@/shared/ui/ChatMessageBubble'
import { RichTextMessageContent } from '@/shared/ui/RichTextMessageContent'
import { RichTextMessageEditor } from '@/shared/ui/RichTextMessageEditor'
@@ -207,6 +208,10 @@ export function AdminOrdersPage() {
#{detail.id.slice(-8)} · {detail.user.email} · {orderStatusLabelRu(detail.status)} ·{' '}
{formatPriceRub(detail.totalCents)}
</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' } }}>
<FormControl size="small" sx={{ minWidth: 240 }}>
@@ -240,25 +245,13 @@ export function AdminOrdersPage() {
</Typography>
<Stack spacing={1} sx={{ mb: 1 }}>
{detail.messages.map((m) => (
<Box
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%',
}}
>
<ChatMessageBubble key={m.id} authorType={m.authorType === 'admin' ? 'user' : 'admin'}>
<Typography variant="caption" color="text.secondary">
{m.authorType === 'admin' ? 'Админ (вы)' : 'Пользователь'} ·{' '}
{new Date(m.createdAt).toLocaleString()}
</Typography>
<RichTextMessageContent value={m.text} tone="chat" />
</Box>
</ChatMessageBubble>
))}
{detail.messages.length === 0 && <Typography color="text.secondary">Нет сообщений.</Typography>}
</Stack>
+1 -15
View File
@@ -2,7 +2,6 @@ import { useEffect, useState } from 'react'
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'
@@ -11,7 +10,6 @@ import { useUnit } from 'effector-react'
import { useForm } from 'react-hook-form'
import { useNavigate, useSearchParams } from 'react-router-dom'
import { apiClient } from '@/shared/api/client'
import { oauthAuthorizeUrl } from '@/shared/lib/oauth-authorize-url'
import { $user, tokenSet } from '@/shared/model/auth'
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 }}>
<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 />
<Typography variant="h6">Вариант 1: Email + код</Typography>
<Typography variant="h6">Email + код</Typography>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2}>
<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 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'
@@ -23,6 +27,7 @@ export function CheckoutPage() {
const qc = useQueryClient()
const navigate = useNavigate()
const [deliveryType, setDeliveryType] = useState<'delivery' | 'pickup'>('delivery')
const [pickupPayment, setPickupPayment] = useState<'online' | 'on_pickup'>('online')
const [addressId, setAddressId] = useState('')
const [comment, setComment] = useState('')
@@ -45,6 +50,7 @@ export function CheckoutPage() {
mutationFn: () =>
createOrder({
deliveryType,
paymentMethod: deliveryType === 'delivery' ? 'online' : pickupPayment,
addressId: deliveryType === 'delivery' ? selectedAddressId : null,
comment: comment.trim() || null,
}),
@@ -134,7 +140,10 @@ export function CheckoutPage() {
value={deliveryType}
onChange={(e) => {
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>
@@ -183,7 +192,35 @@ export function CheckoutPage() {
)}
{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
+1 -6
View File
@@ -371,12 +371,7 @@ export function HomePage() {
product={p}
mediaHeight={mediaHeight}
actions={
!isAdmin ? (
<ToggleCartIcon
productId={p.id}
disabledReason={p.inStock && p.quantity === 0 ? 'Нет в наличии' : null}
/>
) : undefined
!isAdmin && !(p.inStock && p.quantity === 0) ? <ToggleCartIcon productId={p.id} /> : undefined
}
/>
</Grid>
@@ -14,6 +14,7 @@ import { Link as RouterLink } from 'react-router-dom'
import { fetchMyOrder, postOrderMessage } from '@/entities/order/api/order-api'
import { fetchMyConversations, markOrderMessagesRead } from '@/entities/user/api/messages-api'
import { orderStatusLabelRu } from '@/shared/lib/order-status-labels'
import { ChatMessageBubble } from '@/shared/ui/ChatMessageBubble'
import { RichTextMessageContent } from '@/shared/ui/RichTextMessageContent'
import { RichTextMessageEditor } from '@/shared/ui/RichTextMessageEditor'
@@ -114,13 +115,15 @@ export function MessagesPage() {
</Typography>
</Stack>
}
secondaryTypographyProps={{
sx: {
mt: 0.5,
overflow: 'hidden',
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
slotProps={{
secondary: {
sx: {
mt: 0.5,
overflow: 'hidden',
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
},
},
}}
secondary={c.preview}
@@ -160,24 +163,12 @@ export function MessagesPage() {
</Stack>
<Stack spacing={1} sx={{ mb: 2, maxHeight: 360, overflow: 'auto' }}>
{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',
alignSelf: m.authorType === 'admin' ? 'flex-start' : 'flex-end',
width: 'fit-content',
maxWidth: '85%',
}}
>
<ChatMessageBubble key={m.id} authorType={m.authorType === 'admin' ? 'admin' : 'user'}>
<Typography variant="caption" color="text.secondary">
{m.authorType === 'admin' ? 'Админ' : 'Вы'} · {new Date(m.createdAt).toLocaleString()}
</Typography>
<RichTextMessageContent value={m.text} tone="chat" />
</Box>
</ChatMessageBubble>
))}
{order.messages.length === 0 && <Typography color="text.secondary">Нет сообщений.</Typography>}
</Stack>
@@ -7,6 +7,7 @@ import DialogActions from '@mui/material/DialogActions'
import DialogContent from '@mui/material/DialogContent'
import DialogTitle from '@mui/material/DialogTitle'
import Divider from '@mui/material/Divider'
import Link from '@mui/material/Link'
import Rating from '@mui/material/Rating'
import Stack from '@mui/material/Stack'
import Typography from '@mui/material/Typography'
@@ -22,8 +23,10 @@ import {
} from '@/entities/order/api/order-api'
import { postProductReview, uploadReviewImage } from '@/entities/product/api/reviews-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 { orderStatusLabelRu } from '@/shared/lib/order-status-labels'
import { ChatMessageBubble } from '@/shared/ui/ChatMessageBubble'
import { RichTextMessageContent } from '@/shared/ui/RichTextMessageContent'
import { RichTextMessageEditor } from '@/shared/ui/RichTextMessageEditor'
@@ -96,6 +99,7 @@ export function OrderDetailPage() {
})
const order = orderQuery.data?.item
const payOnPickup = (order?.paymentMethod ?? 'online') === 'on_pickup'
const canSendMessage = text.replace(/<[^>]*>/g, ' ').trim().length > 0
const eligibilityQuery = useQuery({
@@ -199,6 +203,7 @@ export function OrderDetailPage() {
</Typography>
<Typography color="text.secondary" variant="body2" sx={{ mb: 1 }}>
Способ: {order.deliveryType === 'pickup' ? 'Самовывоз' : 'Доставка'}
{order.deliveryType === 'pickup' && <> · оплата: {payOnPickup ? 'при получении' : 'онлайн'}</>}
</Typography>
{order.deliveryType === 'delivery' && (
<>
@@ -220,9 +225,21 @@ export function OrderDetailPage() {
</>
)}
{order.deliveryType === 'pickup' && (
<Typography color="text.secondary">
Адрес доставки не требуется. Мы свяжемся для согласования самовывоза.
</Typography>
<Stack spacing={0.75}>
<Typography color="text.secondary" variant="body2">
Адрес самовывоза и карта на странице{' '}
<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 && (
<Typography color="text.secondary" variant="body2" sx={{ mt: 1 }}>
@@ -235,25 +252,33 @@ export function OrderDetailPage() {
<Typography variant="h6" gutterBottom>
Оплата
</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) && (
{payOnPickup ? (
<Typography color="text.secondary" variant="body2">
На этом этапе действий по оплате в этом блоке не требуется.
Оплата при получении на точке самовывоза (наличные или карта по договорённости).
</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>
@@ -316,24 +341,12 @@ export function OrderDetailPage() {
</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',
alignSelf: m.authorType === 'admin' ? 'flex-start' : 'flex-end',
width: 'fit-content',
maxWidth: '85%',
}}
>
<ChatMessageBubble key={m.id} authorType={m.authorType === 'admin' ? 'admin' : 'user'}>
<Typography variant="caption" color="text.secondary">
{m.authorType === 'admin' ? 'Админ' : 'Вы'} · {new Date(m.createdAt).toLocaleString()}
</Typography>
<RichTextMessageContent value={m.text} tone="chat" />
</Box>
</ChatMessageBubble>
))}
{order.messages.length === 0 && <Typography color="text.secondary">Пока сообщений нет.</Typography>}
</Stack>
+1 -1
View File
@@ -155,7 +155,7 @@ export function ProductPage() {
{formatPriceRub(p.priceCents)}
</Typography>
{!isAdmin && <ToggleCartIcon productId={p.id} size="medium" />}
{!isAdmin && !(p.inStock && p.quantity === 0) ? <ToggleCartIcon productId={p.id} size="medium" /> : null}
{!p.inStock && (
<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
const normalizedValue = value.trim() ? value : '<p></p>'
if (editor.getHTML() === normalizedValue) return
editor.commands.setContent(normalizedValue, false)
editor.commands.setContent(normalizedValue, { emitUpdate: false })
}, [editor, value])
return (
<Box
sx={{
...(tone === 'chat' ? { color: 'text.primary' } : {}),
'& .ProseMirror': {
outline: 'none',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
...(tone === 'chat' ? { color: 'inherit' } : {}),
...(tone === 'review'
? {
fontSize: '0.875rem',
@@ -46,7 +46,7 @@ export function RichTextMessageEditor({
if (!editor) return
const normalizedValue = value.trim() ? value : '<p></p>'
if (editor.getHTML() === normalizedValue) return
editor.commands.setContent(normalizedValue, false)
editor.commands.setContent(normalizedValue, { emitUpdate: false })
}, [editor, value])
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")
/// 'delivery' | 'pickup'
deliveryType String @default("delivery")
/// 'online' | 'on_pickup' — способ расчёта для заказа
paymentMethod String @default("online")
itemsSubtotalCents Int @default(0)
deliveryFeeCents Int @default(0)
totalCents Int @default(0)
+1
View File
@@ -57,6 +57,7 @@ export async function registerAdminOrderRoutes(fastify) {
id: o.id,
status: o.status,
deliveryType: o.deliveryType,
paymentMethod: o.paymentMethod,
totalCents: o.totalCents,
currency: o.currency,
createdAt: o.createdAt,
+23 -1
View File
@@ -420,10 +420,23 @@ export async function registerAuthRoutes(fastify) {
const commentRaw = request.body?.comment
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') {
return reply.code(400).send({ error: 'deliveryType должен быть delivery | pickup' })
}
if (paymentMethod === 'on_pickup' && deliveryType !== 'pickup') {
return reply.code(400).send({ error: 'Оплата при получении доступна только для самовывоза' })
}
let address = null
if (deliveryType === 'delivery') {
if (!addressId) return reply.code(400).send({ error: 'Выберите адрес доставки' })
@@ -472,6 +485,8 @@ export async function registerAuthRoutes(fastify) {
lng: address.lng,
})
const initialStatus = paymentMethod === 'on_pickup' ? 'IN_PROGRESS' : 'PENDING_PAYMENT'
let created
try {
created = await prisma.$transaction(async (tx) => {
@@ -491,8 +506,9 @@ export async function registerAuthRoutes(fastify) {
const order = await tx.order.create({
data: {
userId,
status: 'PENDING_PAYMENT',
status: initialStatus,
deliveryType,
paymentMethod,
itemsSubtotalCents,
deliveryFeeCents,
totalCents,
@@ -678,6 +694,12 @@ export async function registerAuthRoutes(fastify) {
const { id } = request.params
const order = await prisma.order.findFirst({ where: { id, userId } })
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
if (order.status === 'DRAFT') {
await prisma.order.update({ where: { id }, data: { status: 'PENDING_PAYMENT' } })