base commit

This commit is contained in:
@kirill.komarov
2026-04-30 22:34:55 +05:00
parent 123d86091d
commit 9139a24093
46 changed files with 2023 additions and 153 deletions
+2 -1
View File
@@ -2,7 +2,7 @@ import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'
import { MainLayout } from '@/app/layout/MainLayout'
import { AppProviders } from '@/app/providers/AppProviders'
import { AdminLayoutPage } from '@/pages/admin-layout'
import { AuthPage } from '@/pages/auth'
import { AuthCallbackPage, AuthPage } from '@/pages/auth'
import { CartPage } from '@/pages/cart'
import { CheckoutPage } from '@/pages/checkout'
import { HomePage } from '@/pages/home'
@@ -18,6 +18,7 @@ export function App() {
<Route path="/" element={<HomePage />} />
<Route path="/admin/*" element={<AdminLayoutPage />} />
<Route path="/auth" element={<AuthPage />} />
<Route path="/auth/callback" element={<AuthCallbackPage />} />
<Route path="/cart" element={<CartPage />} />
<Route path="/checkout" element={<CheckoutPage />} />
<Route path="/me/*" element={<MeLayoutPage />} />
+8 -8
View File
@@ -62,11 +62,11 @@ function ThemeControlsDesktop(props: {
'& .MuiInputLabel-root.MuiInputLabel-shrink': { color: 'rgba(255,255,255,0.92)' },
}}
>
<InputLabel id="scheme-label">Тема</InputLabel>
<InputLabel id="scheme-label">Схема</InputLabel>
<Select
labelId="scheme-label"
value={scheme}
label="Тема"
label="Схема"
onChange={onSchemeChange}
sx={{
color: 'inherit',
@@ -93,11 +93,11 @@ function ThemeControlsDesktop(props: {
'& .MuiInputLabel-root.MuiInputLabel-shrink': { color: 'rgba(255,255,255,0.92)' },
}}
>
<InputLabel id="mode-label">Режим</InputLabel>
<InputLabel id="mode-label">Тема</InputLabel>
<Select
labelId="mode-label"
value={mode}
label="Режим"
label="Тема"
onChange={onModeChange}
sx={{
color: 'inherit',
@@ -139,8 +139,8 @@ function ThemeControlsMobile(props: {
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<FormControl size="small" fullWidth>
<InputLabel id="scheme-label-mobile">Тема</InputLabel>
<Select labelId="scheme-label-mobile" value={scheme} label="Тема" onChange={onSchemeChange}>
<InputLabel id="scheme-label-mobile">Схема</InputLabel>
<Select labelId="scheme-label-mobile" value={scheme} label="Схема" onChange={onSchemeChange}>
<MenuItem value="craft">Крафт</MenuItem>
<MenuItem value="forest">Лес</MenuItem>
<MenuItem value="ocean">Океан</MenuItem>
@@ -149,8 +149,8 @@ function ThemeControlsMobile(props: {
</FormControl>
<FormControl size="small" fullWidth>
<InputLabel id="mode-label-mobile">Режим</InputLabel>
<Select labelId="mode-label-mobile" value={mode} label="Режим" onChange={onModeChange}>
<InputLabel id="mode-label-mobile">Тема</InputLabel>
<Select labelId="mode-label-mobile" value={mode} label="Тема" onChange={onModeChange}>
<MenuItem value="system">Авто (система)</MenuItem>
<MenuItem value="light">Светлая</MenuItem>
<MenuItem value="dark">Тёмная</MenuItem>
+71 -4
View File
@@ -1,10 +1,18 @@
import { type PropsWithChildren } from 'react'
import Box from '@mui/material/Box'
import Container from '@mui/material/Container'
import Divider from '@mui/material/Divider'
import Grid from '@mui/material/Grid'
import Link from '@mui/material/Link'
import Stack from '@mui/material/Stack'
import Typography from '@mui/material/Typography'
import { Link as RouterLink } from 'react-router-dom'
import { AppHeader } from '@/app/layout/AppHeader'
import { STORE_EMAIL, STORE_NAME, STORE_PHONE, STORE_SOCIAL_NOTE } from '@/shared/config'
export function MainLayout({ children }: PropsWithChildren) {
const year = new Date().getFullYear()
return (
<Box sx={{ display: 'flex', flexDirection: 'column', minHeight: '100vh' }}>
<AppHeader />
@@ -12,17 +20,76 @@ export function MainLayout({ children }: PropsWithChildren) {
<Box component="main" sx={{ flex: 1, py: 3 }}>
<Container maxWidth="lg">{children}</Container>
</Box>
<Box
component="footer"
sx={{
py: 2,
textAlign: 'center',
mt: 'auto',
borderTop: 1,
borderColor: 'divider',
color: 'text.secondary',
bgcolor: 'background.default',
py: { xs: 3, md: 4 },
}}
>
<Typography variant="body2">Изделия ручной работы · доставка по договорённости</Typography>
<Container maxWidth="lg">
<Grid container spacing={3}>
<Grid size={{ xs: 12, sm: 4 }}>
<Typography variant="subtitle1" fontWeight={700} gutterBottom>
Магазин
</Typography>
<Stack spacing={1}>
<Link component={RouterLink} to="/" color="inherit" underline="hover" variant="body2">
Каталог
</Link>
<Typography variant="body2" color="text.secondary">
Изделия ручной работы: вещь с характером и вниманием к деталям.
</Typography>
<Typography variant="body2" color="text.secondary">
Как заказать: добавьте позиции в корзину и оформите доставку или самовывоз на чек-ауте.
</Typography>
</Stack>
</Grid>
<Grid size={{ xs: 12, sm: 4 }}>
<Typography variant="subtitle1" fontWeight={700} gutterBottom>
Покупателям
</Typography>
<Stack spacing={1}>
<Link component={RouterLink} to="/me" color="inherit" underline="hover" variant="body2">
Личный кабинет
</Link>
<Typography variant="body2" color="text.secondary">
Доставка и самовывоз: уточняются при оформлении заказа; по вопросам контакты ниже.
</Typography>
</Stack>
</Grid>
<Grid size={{ xs: 12, sm: 4 }}>
<Typography variant="subtitle1" fontWeight={700} gutterBottom>
Контакты
</Typography>
<Stack spacing={0.75}>
<Typography variant="body2">
Email:{' '}
<Link href={`mailto:${STORE_EMAIL}`} underline="hover">
{STORE_EMAIL}
</Link>
</Typography>
<Typography variant="body2">
Телефон:{' '}
<Link href={`tel:${STORE_PHONE.replace(/\s/g, '')}`} underline="hover">
{STORE_PHONE}
</Link>
</Typography>
<Typography variant="caption" color="text.secondary">
{STORE_SOCIAL_NOTE}
</Typography>
</Stack>
</Grid>
</Grid>
<Divider sx={{ my: 2 }} />
<Typography variant="caption" color="text.secondary" display="block" textAlign={{ xs: 'left', sm: 'center' }}>
© {year} {STORE_NAME}. Сделано для демонстрации возможностей витрины.
</Typography>
</Container>
</Box>
</Box>
)
@@ -3,6 +3,7 @@ import { apiClient } from '@/shared/api/client'
export type AdminOrderListItem = {
id: string
status: string
deliveryType: 'delivery' | 'pickup'
totalCents: number
currency: string
createdAt: string
@@ -22,9 +23,12 @@ export type AdminOrderDetailResponse = {
item: {
id: string
status: string
deliveryType: 'delivery' | 'pickup'
itemsSubtotalCents: number
deliveryFeeCents: number
totalCents: number
currency: string
addressSnapshotJson: string
addressSnapshotJson: string | null
comment: string | null
createdAt: string
updatedAt: string
@@ -45,9 +49,16 @@ export type AdminOrderDetailResponse = {
}
}
export async function fetchAdminOrdersSummary(token: string): Promise<{ attentionCount: number }> {
const { data } = await apiClient.get<{ attentionCount: number }>('admin/orders/summary', {
headers: { Authorization: `Bearer ${token}` },
})
return data
}
export async function fetchAdminOrders(
token: string,
params?: { status?: string; q?: string; page?: number; pageSize?: number },
params?: { status?: string; deliveryType?: 'delivery' | 'pickup'; q?: string; page?: number; pageSize?: number },
): Promise<AdminOrdersListResponse> {
const { data } = await apiClient.get<AdminOrdersListResponse>('admin/orders', {
params,
+29 -4
View File
@@ -16,9 +16,12 @@ export type OrderDetailResponse = {
item: {
id: string
status: string
deliveryType: 'delivery' | 'pickup'
itemsSubtotalCents: number
deliveryFeeCents: number
totalCents: number
currency: string
addressSnapshotJson: string
addressSnapshotJson: string | null
comment: string | null
createdAt: string
updatedAt: string
@@ -38,7 +41,11 @@ export type OrderDetailResponse = {
}
}
export async function createOrder(body: { addressId: string; comment?: string | null }): Promise<{ orderId: string }> {
export async function createOrder(body: {
deliveryType: 'delivery' | 'pickup'
addressId?: string | null
comment?: string | null
}): Promise<{ orderId: string }> {
const { data } = await apiClient.post<{ orderId: string }>('me/orders', body)
return data
}
@@ -57,6 +64,24 @@ 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`)
export async function payOrderStub(id: string): Promise<{ ok: boolean; status: string }> {
const { data } = await apiClient.post<{ ok: boolean; status: string }>(`me/orders/${id}/pay`)
return data
}
export async function confirmOrderReceived(id: string): Promise<{ ok: boolean; status: string }> {
const { data } = await apiClient.post<{ ok: boolean; status: string }>(`me/orders/${id}/confirm-received`)
return data
}
export type ReviewEligibilityItem = { productId: string; title: string; hasReview: boolean }
export async function fetchOrderReviewEligibility(orderId: string): Promise<{
canReview: boolean
items: ReviewEligibilityItem[]
}> {
const { data } = await apiClient.get<{ canReview: boolean; items: ReviewEligibilityItem[] }>(
`me/orders/${orderId}/review-eligibility`,
)
return data
}
@@ -12,6 +12,7 @@ export async function fetchPublicProducts(params?: {
categorySlug?: string
q?: string
sort?: 'price_asc' | 'price_desc' | ''
availability?: 'all' | 'in_stock' | 'made_to_order'
page?: number
pageSize?: number
priceMinCents?: number
@@ -22,6 +23,7 @@ export async function fetchPublicProducts(params?: {
categorySlug: params?.categorySlug || undefined,
q: params?.q || undefined,
sort: params?.sort || undefined,
availability: params?.availability || undefined,
page: params?.page || undefined,
pageSize: params?.pageSize || undefined,
priceMin: params?.priceMinCents ?? undefined,
@@ -0,0 +1,54 @@
import { apiClient } from '@/shared/api/client'
export async function postProductReview(
productId: string,
body: { rating: number; text?: string | null },
): Promise<void> {
await apiClient.post(`products/${productId}/reviews`, body)
}
export type PublicReviewFeedItem = {
id: string
rating: number
text: string | null
createdAt: string
authorDisplay: string
productId: string
productTitle: string
}
export type PublicReviewsLatestResponse = {
items: PublicReviewFeedItem[]
}
export async function fetchLatestApprovedReviews(limit = 5): Promise<PublicReviewsLatestResponse> {
const { data } = await apiClient.get<PublicReviewsLatestResponse>('reviews/latest', {
params: { limit },
})
return data
}
export type PublicProductReviewItem = {
id: string
rating: number
text: string | null
createdAt: string
authorDisplay: string
}
export type PublicProductReviewsResponse = {
items: PublicProductReviewItem[]
total: number
page: number
pageSize: number
}
export async function fetchPublicProductReviews(
productId: string,
params?: { page?: number; pageSize?: number },
): Promise<PublicProductReviewsResponse> {
const { data } = await apiClient.get<PublicProductReviewsResponse>(`products/${productId}/reviews`, {
params: { page: params?.page, pageSize: params?.pageSize },
})
return data
}
@@ -5,6 +5,12 @@ export type Category = {
sort: number
}
export type ProductReviewsSummary = {
approvedReviewCount: number
avgRating: number | null
latestApprovedText: string | null
}
export type Product = {
id: string
title: string
@@ -24,4 +30,6 @@ export type Product = {
updatedAt: string
category?: Category
images?: { id: string; url: string; sort: number }[]
/** Для опубликованных товаров с публичного API. */
reviewsSummary?: ProductReviewsSummary | null
}
@@ -6,6 +6,7 @@ import CardContent from '@mui/material/CardContent'
import CardMedia from '@mui/material/CardMedia'
import Chip from '@mui/material/Chip'
import Link from '@mui/material/Link'
import Rating from '@mui/material/Rating'
import Stack from '@mui/material/Stack'
import Typography from '@mui/material/Typography'
import { Link as RouterLink } from 'react-router-dom'
@@ -13,6 +14,7 @@ import { Swiper, SwiperSlide } from 'swiper/react'
import 'swiper/css'
import type { Product } from '@/entities/product/model/types'
import { formatPriceRub } from '@/shared/lib/format-price'
import { reviewsCountRu } from '@/shared/lib/reviews-count-ru'
import type { Swiper as SwiperType } from 'swiper/types'
type Props = { product: Product; mediaHeight?: number; actions?: ReactNode }
@@ -117,6 +119,7 @@ export function ProductCard({ product, mediaHeight = 200, actions }: Props) {
<CardContent sx={{ flexGrow: 1 }}>
<Stack spacing={1}>
{product.category && <Chip label={product.category.name} size="small" />}
{product.inStock && product.quantity === 0 && <Chip label="Нет в наличии" size="small" color="default" />}
{!product.inStock && (
<Chip
label={`Под заказ · ${product.leadTimeDays ?? '—'} дн.`}
@@ -157,6 +160,38 @@ export function ProductCard({ product, mediaHeight = 200, actions }: Props) {
<Typography variant="h6" color="primary">
{formatPriceRub(product.priceCents)}
</Typography>
{product.reviewsSummary && product.reviewsSummary.approvedReviewCount > 0 && (
<Stack spacing={0.5}>
<Stack direction="row" spacing={0.75} sx={{ alignItems: 'center' }}>
<Rating
size="small"
value={product.reviewsSummary.avgRating ?? 0}
precision={0.25}
readOnly
sx={{ color: 'warning.main', fontSize: 18 }}
/>
<Typography variant="caption" color="text.secondary">
{reviewsCountRu(product.reviewsSummary.approvedReviewCount)}
</Typography>
</Stack>
{product.reviewsSummary.latestApprovedText ? (
<Typography
variant="caption"
color="text.secondary"
sx={{
fontStyle: 'italic',
overflow: 'hidden',
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
}}
title={product.reviewsSummary.latestApprovedText}
>
«{product.reviewsSummary.latestApprovedText}»
</Typography>
) : null}
</Stack>
)}
{actions}
</Stack>
</CardContent>
@@ -0,0 +1,24 @@
import { apiClient } from '@/shared/api/client'
export async function fetchUnreadMessageCount(): Promise<{ count: number }> {
const { data } = await apiClient.get<{ count: number }>('me/messages/unread-count')
return data
}
export async function markOrderMessagesRead(orderId: string): Promise<void> {
await apiClient.post(`me/orders/${orderId}/messages/read`)
}
export type ConversationSummary = {
orderId: string
status: string
deliveryType: 'delivery' | 'pickup'
lastMessageAt: string
preview: string
unreadCount: number
}
export async function fetchMyConversations(): Promise<{ items: ConversationSummary[] }> {
const { data } = await apiClient.get<{ items: ConversationSummary[] }>('me/conversations')
return data
}
@@ -9,8 +9,12 @@ import { useNavigate } from 'react-router-dom'
import { addToCart, fetchMyCart, removeCartItem } from '@/entities/cart/api/cart-api'
import { $user } from '@/shared/model/auth'
export function ToggleCartIcon(props: { productId: string; size?: 'small' | 'medium' }) {
const { productId, size = 'small' } = props
export function ToggleCartIcon(props: {
productId: string
size?: 'small' | 'medium'
disabledReason?: string | null
}) {
const { productId, size = 'small', disabledReason = null } = props
const user = useUnit($user)
const qc = useQueryClient()
const navigate = useNavigate()
@@ -34,12 +38,13 @@ export function ToggleCartIcon(props: { productId: string; size?: 'small' | 'med
onSuccess: () => void qc.invalidateQueries({ queryKey: ['me', 'cart'] }),
})
const disabled = !user
const disabled = !user || Boolean(disabledReason)
const busy = addMut.isPending || removeMut.isPending
const onClick = (e: React.MouseEvent) => {
e.preventDefault()
e.stopPropagation()
if (disabledReason) return
if (!user) {
navigate('/auth')
return
@@ -48,7 +53,13 @@ export function ToggleCartIcon(props: { productId: string; size?: 'small' | 'med
else addMut.mutate()
}
const tooltip = !user ? 'Авторизуйтесь для совершения покупок' : inCart ? 'Убрать из корзины' : 'В корзину'
const tooltip = disabledReason
? disabledReason
: !user
? 'Авторизуйтесь для совершения покупок'
: inCart
? 'Убрать из корзины'
: 'В корзину'
return (
<Tooltip title={tooltip}>
@@ -1,10 +1,11 @@
import type { ReactNode } from 'react'
import { useMemo, useState } from 'react'
import { useMemo, useState, useSyncExternalStore } from 'react'
import AssignmentOutlinedIcon from '@mui/icons-material/AssignmentOutlined'
import MenuOutlinedIcon from '@mui/icons-material/MenuOutlined'
import PeopleOutlinedIcon from '@mui/icons-material/PeopleOutlined'
import RateReviewOutlinedIcon from '@mui/icons-material/RateReviewOutlined'
import StorefrontOutlinedIcon from '@mui/icons-material/StorefrontOutlined'
import Badge from '@mui/material/Badge'
import Box from '@mui/material/Box'
import Divider from '@mui/material/Divider'
import Drawer from '@mui/material/Drawer'
@@ -17,11 +18,14 @@ import Stack from '@mui/material/Stack'
import { useTheme } from '@mui/material/styles'
import Typography from '@mui/material/Typography'
import useMediaQuery from '@mui/material/useMediaQuery'
import { useQuery } from '@tanstack/react-query'
import { Navigate, Route, Routes, useLocation, useNavigate } from 'react-router-dom'
import { fetchAdminOrdersSummary } from '@/entities/order/api/admin-order-api'
import { AdminPage } from '@/pages/admin'
import { AdminOrdersPage } from '@/pages/admin-orders'
import { AdminReviewsPage } from '@/pages/admin-reviews'
import { AdminUsersPage } from '@/pages/admin-users'
import { getAdminToken, subscribeAdminTokenChange } from '@/shared/lib/admin-token'
type NavItem = {
to: string
@@ -35,6 +39,17 @@ export function AdminLayoutPage() {
const theme = useTheme()
const isMobile = useMediaQuery(theme.breakpoints.down('md'))
const [mobileOpen, setMobileOpen] = useState(false)
const adminToken = useSyncExternalStore(subscribeAdminTokenChange, getAdminToken, () => null)
const ordersSummaryQuery = useQuery({
queryKey: ['admin', 'orders', 'summary', adminToken],
queryFn: () => fetchAdminOrdersSummary(adminToken!),
enabled: Boolean(adminToken),
refetchInterval: 45_000,
refetchOnWindowFocus: true,
})
const newOrdersAttention = ordersSummaryQuery.data?.attentionCount ?? 0
const navItems: NavItem[] = useMemo(
() => [
@@ -72,7 +87,15 @@ export function AdminLayoutPage() {
setMobileOpen(false)
}}
>
<ListItemIcon>{i.icon}</ListItemIcon>
<ListItemIcon>
{i.to === '/admin/orders' ? (
<Badge color="error" badgeContent={newOrdersAttention} max={99} invisible={newOrdersAttention === 0}>
{i.icon}
</Badge>
) : (
i.icon
)}
</ListItemIcon>
<ListItemText primary={i.label} />
</ListItemButton>
))}
@@ -26,9 +26,10 @@ import {
postAdminOrderMessage,
setAdminOrderStatus,
} from '@/entities/order/api/admin-order-api'
import { ORDER_STATUS_TRANSITIONS, type OrderStatus } from '@/shared/constants/order'
import { ORDER_STATUSES, getAdminNextOrderStatuses } from '@/shared/constants/order'
import { clearAdminToken, getAdminToken, setAdminToken } from '@/shared/lib/admin-token'
import { formatPriceRub } from '@/shared/lib/format-price'
import { orderStatusLabelRu } from '@/shared/lib/order-status-labels'
type TokenFormState = { token: string }
@@ -37,6 +38,7 @@ export function AdminOrdersPage() {
const [token, setTokenState] = useState<string | null>(() => getAdminToken())
const [q, setQ] = useState('')
const [status, setStatus] = useState('')
const [deliveryType, setDeliveryType] = useState<'delivery' | 'pickup' | ''>('')
const [dialogOpen, setDialogOpen] = useState(false)
const [selectedId, setSelectedId] = useState<string | null>(null)
const [msg, setMsg] = useState('')
@@ -59,8 +61,13 @@ export function AdminOrdersPage() {
}
const ordersQuery = useQuery({
queryKey: ['admin', 'orders', token, { q, status }],
queryFn: () => fetchAdminOrders(token!, { q: q.trim() || undefined, status: status || undefined }),
queryKey: ['admin', 'orders', token, { q, status, deliveryType }],
queryFn: () =>
fetchAdminOrders(token!, {
q: q.trim() || undefined,
status: status || undefined,
deliveryType: deliveryType || undefined,
}),
enabled: Boolean(token),
})
@@ -75,6 +82,7 @@ export function AdminOrdersPage() {
onSuccess: async () => {
await qc.invalidateQueries({ queryKey: ['admin', 'orders'] })
await qc.invalidateQueries({ queryKey: ['admin', 'orders', 'detail'] })
await qc.invalidateQueries({ queryKey: ['admin', 'orders', 'summary'] })
},
})
@@ -96,10 +104,9 @@ export function AdminOrdersPage() {
const detail = orderDetailQuery.data?.item
const nextStatuses = useMemo(() => {
const s = detail?.status
if (!s) return []
return ORDER_STATUS_TRANSITIONS[s as OrderStatus] ?? []
}, [detail?.status])
if (!detail) return []
return getAdminNextOrderStatuses(detail.status, detail.deliveryType ?? 'delivery')
}, [detail])
return (
<Box>
@@ -153,13 +160,31 @@ export function AdminOrdersPage() {
<MenuItem value="">
<em>Все</em>
</MenuItem>
{Object.keys(ORDER_STATUS_TRANSITIONS).map((s) => (
{ORDER_STATUSES.map((s) => (
<MenuItem key={s} value={s}>
{s}
{orderStatusLabelRu(s)}
</MenuItem>
))}
</Select>
</FormControl>
<FormControl size="small" sx={{ minWidth: 220 }}>
<InputLabel id="delivery-type-label">Способ получения</InputLabel>
<Select
labelId="delivery-type-label"
label="Способ получения"
value={deliveryType}
onChange={(e) => {
const v = String(e.target.value)
if (v === '' || v === 'delivery' || v === 'pickup') setDeliveryType(v)
}}
>
<MenuItem value="">
<em>Все</em>
</MenuItem>
<MenuItem value="delivery">Доставка</MenuItem>
<MenuItem value="pickup">Самовывоз</MenuItem>
</Select>
</FormControl>
</Stack>
{ordersQuery.isError && <Alert severity="error">Не удалось загрузить заказы.</Alert>}
@@ -180,7 +205,7 @@ export function AdminOrdersPage() {
<TableRow key={o.id} hover>
<TableCell>{o.id.slice(-8)}</TableCell>
<TableCell>{o.user.email}</TableCell>
<TableCell>{o.status}</TableCell>
<TableCell>{orderStatusLabelRu(o.status)}</TableCell>
<TableCell>{formatPriceRub(o.totalCents)}</TableCell>
<TableCell>{o.itemsCount}</TableCell>
<TableCell align="right">
@@ -210,7 +235,8 @@ export function AdminOrdersPage() {
{detail && (
<Stack spacing={2} sx={{ mt: 1 }}>
<Typography sx={{ fontWeight: 700 }}>
#{detail.id.slice(-8)} · {detail.user.email} · {detail.status} · {formatPriceRub(detail.totalCents)}
#{detail.id.slice(-8)} · {detail.user.email} · {orderStatusLabelRu(detail.status)} ·{' '}
{formatPriceRub(detail.totalCents)}
</Typography>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} sx={{ alignItems: { sm: 'center' } }}>
@@ -232,7 +258,7 @@ export function AdminOrdersPage() {
</MenuItem>
{nextStatuses.map((s) => (
<MenuItem key={s} value={s}>
{s}
{orderStatusLabelRu(s)}
</MenuItem>
))}
</Select>
+1
View File
@@ -1 +1,2 @@
export { AuthCallbackPage } from './ui/AuthCallbackPage'
export { AuthPage } from './ui/AuthPage'
@@ -0,0 +1,28 @@
import { useEffect } from 'react'
import Box from '@mui/material/Box'
import CircularProgress from '@mui/material/CircularProgress'
import Typography from '@mui/material/Typography'
import { useNavigate, useSearchParams } from 'react-router-dom'
import { tokenSet } from '@/shared/model/auth'
export function AuthCallbackPage() {
const [params] = useSearchParams()
const navigate = useNavigate()
useEffect(() => {
const t = params.get('token')
if (t) {
tokenSet(t)
navigate('/', { replace: true })
return
}
navigate('/auth', { replace: true })
}, [navigate, params])
return (
<Box sx={{ py: 6, display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 2 }}>
<CircularProgress />
<Typography color="text.secondary">Завершение входа</Typography>
</Box>
)
}
+28 -1
View File
@@ -9,8 +9,9 @@ import Typography from '@mui/material/Typography'
import { useMutation } from '@tanstack/react-query'
import { useUnit } from 'effector-react'
import { useForm } from 'react-hook-form'
import { useNavigate } from 'react-router-dom'
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 } }
@@ -26,6 +27,8 @@ function getApiErrorMessage(err: unknown): string | null {
export function AuthPage() {
const [message, setMessage] = useState<string | null>(null)
const [oauthError, setOauthError] = useState<string | null>(null)
const [searchParams, setSearchParams] = useSearchParams()
const navigate = useNavigate()
const user = useUnit($user)
const { register, watch } = useForm<{
@@ -45,6 +48,13 @@ export function AuthPage() {
if (user) navigate('/', { replace: true })
}, [navigate, user])
useEffect(() => {
const err = searchParams.get('oauthError')
if (!err) return
setOauthError(err)
setSearchParams({}, { replace: true })
}, [searchParams, setSearchParams])
const requestCode = useMutation({
mutationFn: async () => {
await apiClient.post('auth/request-code', { email })
@@ -94,6 +104,11 @@ export function AuthPage() {
{message}
</Alert>
)}
{oauthError && (
<Alert severity="error" sx={{ mb: 2 }} onClose={() => setOauthError(null)}>
{oauthError}
</Alert>
)}
{errMsg && (
<Alert severity="error" sx={{ mb: 2 }}>
{errMsg}
@@ -101,6 +116,18 @@ 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>
+79 -22
View File
@@ -22,6 +22,7 @@ export function CheckoutPage() {
const user = useUnit($user)
const qc = useQueryClient()
const navigate = useNavigate()
const [deliveryType, setDeliveryType] = useState<'delivery' | 'pickup'>('delivery')
const [addressId, setAddressId] = useState('')
const [comment, setComment] = useState('')
@@ -41,7 +42,12 @@ export function CheckoutPage() {
const selectedAddressId = addressId || defaultAddressId
const createMut = useMutation({
mutationFn: () => createOrder({ addressId: selectedAddressId, comment: comment.trim() || null }),
mutationFn: () =>
createOrder({
deliveryType,
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 })
@@ -61,7 +67,11 @@ export function CheckoutPage() {
}
const items = cartQuery.data?.items ?? []
const total = items.reduce((s, x) => s + x.product.priceCents * x.qty, 0)
const itemsSubtotalCents = items.reduce((s, x) => s + x.product.priceCents * x.qty, 0)
const totalQty = items.reduce((s, x) => s + x.qty, 0)
const deliveryFeeCents =
deliveryType === 'delivery' && items.length > 0 ? 50000 * Math.max(1, Math.ceil(totalQty / 2)) : 0
const total = itemsSubtotalCents + deliveryFeeCents
const addresses = addressesQuery.data?.items ?? []
const hasOverLimit = items.some((x) => x.qty > (x.product.inStock ? x.product.quantity : 1))
const hasMadeToOrder = items.some((x) => !x.product.inStock)
@@ -117,29 +127,63 @@ export function CheckoutPage() {
)}
<FormControl size="small" fullWidth>
<InputLabel id="addr-label">Адрес доставки</InputLabel>
<InputLabel id="delivery-type-label">Способ получения</InputLabel>
<Select
labelId="addr-label"
label="Адрес доставки"
value={selectedAddressId}
onChange={(e) => setAddressId(String(e.target.value))}
labelId="delivery-type-label"
label="Способ получения"
value={deliveryType}
onChange={(e) => {
const v = String(e.target.value)
if (v === 'delivery' || v === 'pickup') setDeliveryType(v)
}}
>
{addresses.map((a) => (
<MenuItem key={a.id} value={a.id}>
{(a.label?.trim() ? `${a.label}: ` : '') + a.addressLine}
</MenuItem>
))}
<MenuItem value="delivery">Доставка</MenuItem>
<MenuItem value="pickup">Самовывоз</MenuItem>
</Select>
</FormControl>
{addresses.length === 0 && (
<Alert severity="warning">
У вас нет адресов доставки. Добавьте адрес в{' '}
<Typography component={RouterLink} to="/me/addresses" sx={{ textDecoration: 'underline' }}>
кабинете
</Typography>
.
</Alert>
{deliveryType === 'delivery' && (
<>
<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">
Стоимость доставки: 500 за каждые 2 единицы (минимум 500 ).
{items.length > 0 && (
<>
{' '}
В этом заказе: {totalQty} шт. доставка {formatPriceRub(deliveryFeeCents)}.
</>
)}
</Alert>
</>
)}
{deliveryType === 'pickup' && (
<Alert severity="info">Самовывоз: адрес доставки не нужен. Мы свяжемся с вами для согласования.</Alert>
)}
<TextField
@@ -151,12 +195,25 @@ export function CheckoutPage() {
minRows={2}
/>
<Typography variant="h6">Итого: {formatPriceRub(total)}</Typography>
<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 || addresses.length === 0 || !selectedAddressId || hasOverLimit || createMut.isPending
items.length === 0 ||
hasOverLimit ||
createMut.isPending ||
(deliveryType === 'delivery' && (addresses.length === 0 || !selectedAddressId))
}
onClick={() => createMut.mutate()}
>
+78 -12
View File
@@ -22,9 +22,11 @@ import { useQuery } from '@tanstack/react-query'
import { fetchCategories, fetchPublicProducts } from '@/entities/product/api/product-api'
import { ProductCard } from '@/entities/product/ui/ProductCard'
import { ToggleCartIcon } from '@/features/cart/toggle-cart-icon'
import { ReviewsBlock } from '@/widgets/reviews-block'
export function HomePage() {
const [categorySlug, setCategorySlug] = useState<string>('')
const [availability, setAvailability] = useState<'all' | 'in_stock' | 'made_to_order'>('all')
const [qInput, setQInput] = useState('')
const [q, setQ] = useState('')
const [moreOpen, setMoreOpen] = useState(false)
@@ -54,6 +56,7 @@ export function HomePage() {
'public',
{
categorySlug: categorySlug || 'all',
availability,
q,
sort,
page,
@@ -69,6 +72,7 @@ export function HomePage() {
}
return fetchPublicProducts({
categorySlug: categorySlug || undefined,
availability: availability === 'all' ? undefined : availability,
q: q || undefined,
sort: sort || '',
page,
@@ -155,6 +159,52 @@ export function HomePage() {
/>
</Stack>
<Paper
variant="outlined"
sx={{
p: 1.5,
borderRadius: 2,
bgcolor: 'background.paper',
display: 'flex',
flexDirection: { xs: 'column', sm: 'row' },
gap: 1.5,
alignItems: { sm: 'center' },
justifyContent: 'space-between',
}}
>
<Box>
<Typography variant="subtitle2">Наличие</Typography>
<Typography variant="caption" color="text.secondary">
Быстрый фильтр по наличию
</Typography>
</Box>
<ToggleButtonGroup
exclusive
size="small"
value={availability}
onChange={(_, v) => {
if (v === 'all' || v === 'in_stock' || v === 'made_to_order') {
setAvailability(v)
setPage(1)
}
}}
sx={{
alignSelf: { xs: 'flex-start', sm: 'auto' },
'& .MuiToggleButton-root': { px: 2, fontWeight: 700, letterSpacing: 0.2, textTransform: 'none' },
'& .MuiToggleButton-root.Mui-selected': {
bgcolor: 'primary.main',
color: 'primary.contrastText',
'&:hover': { bgcolor: 'primary.dark' },
},
}}
>
<ToggleButton value="all">Все</ToggleButton>
<ToggleButton value="in_stock">В наличии</ToggleButton>
<ToggleButton value="made_to_order">Под заказ</ToggleButton>
</ToggleButtonGroup>
</Paper>
<Stack
direction={{ xs: 'column', sm: 'row' }}
spacing={1.5}
@@ -167,6 +217,7 @@ export function HomePage() {
variant="outlined"
onClick={() => {
setCategorySlug('')
setAvailability('all')
setQInput('')
setSort('')
setPriceMinRub('')
@@ -312,22 +363,37 @@ export function HomePage() {
<Grid container spacing={2}>
{products.map((p) => (
<Grid size={{ xs: 12, sm: 6, md: 4 }} key={p.id}>
<ProductCard product={p} mediaHeight={mediaHeight} actions={<ToggleCartIcon productId={p.id} />} />
<ProductCard
product={p}
mediaHeight={mediaHeight}
actions={
<ToggleCartIcon
productId={p.id}
disabledReason={p.inStock && p.quantity === 0 ? 'Нет в наличии' : null}
/>
}
/>
</Grid>
))}
</Grid>
<Stack direction="row" sx={{ mt: 3, justifyContent: 'center' }}>
<Pagination
page={page}
count={totalPages}
onChange={(_, v) => setPage(v)}
color="primary"
shape="rounded"
showFirstButton
showLastButton
/>
</Stack>
{totalPages > 1 && (
<Stack direction="row" sx={{ mt: 3, justifyContent: 'center' }}>
<Pagination
page={page}
count={totalPages}
onChange={(_, v) => setPage(v)}
color="primary"
shape="rounded"
showFirstButton
showLastButton
/>
</Stack>
)}
<Box sx={{ mt: 4 }}>
<ReviewsBlock />
</Box>
</>
)}
</Box>
+26 -1
View File
@@ -6,6 +6,7 @@ import MenuOutlinedIcon from '@mui/icons-material/MenuOutlined'
import PlaceOutlinedIcon from '@mui/icons-material/PlaceOutlined'
import SettingsOutlinedIcon from '@mui/icons-material/SettingsOutlined'
import Alert from '@mui/material/Alert'
import Badge from '@mui/material/Badge'
import Box from '@mui/material/Box'
import Divider from '@mui/material/Divider'
import Drawer from '@mui/material/Drawer'
@@ -18,8 +19,10 @@ import Stack from '@mui/material/Stack'
import { useTheme } from '@mui/material/styles'
import Typography from '@mui/material/Typography'
import useMediaQuery from '@mui/material/useMediaQuery'
import { useQuery } from '@tanstack/react-query'
import { useUnit } from 'effector-react'
import { Navigate, Route, Routes, useLocation, useNavigate } from 'react-router-dom'
import { fetchUnreadMessageCount } from '@/entities/user/api/messages-api'
import { AddressesPage } from '@/pages/me/ui/sections/AddressesPage'
import { MessagesPage } from '@/pages/me/ui/sections/MessagesPage'
import { OrderDetailPage } from '@/pages/me/ui/sections/OrderDetailPage'
@@ -41,6 +44,16 @@ export function MeLayoutPage() {
const isMobile = useMediaQuery(theme.breakpoints.down('md'))
const [mobileOpen, setMobileOpen] = useState(false)
const unreadQuery = useQuery({
queryKey: ['me', 'messages', 'unread-count'],
queryFn: fetchUnreadMessageCount,
enabled: Boolean(user),
refetchInterval: 45_000,
refetchOnWindowFocus: true,
})
const unreadMessages = unreadQuery.data?.count ?? 0
const navItems: NavItem[] = useMemo(
() => [
{ to: '/me/orders', label: 'Заказы', icon: <LocalShippingOutlinedIcon /> },
@@ -81,7 +94,19 @@ export function MeLayoutPage() {
setMobileOpen(false)
}}
>
<ListItemIcon>{i.icon}</ListItemIcon>
<ListItemIcon>
{i.to === '/me/messages' ? (
<Badge
color="error"
badgeContent={unreadMessages > 99 ? '99+' : unreadMessages}
invisible={unreadMessages === 0}
>
{i.icon}
</Badge>
) : (
i.icon
)}
</ListItemIcon>
<ListItemText primary={i.label} />
</ListItemButton>
))}
@@ -1,13 +1,204 @@
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 Chip from '@mui/material/Chip'
import List from '@mui/material/List'
import ListItem from '@mui/material/ListItem'
import ListItemButton from '@mui/material/ListItemButton'
import ListItemText from '@mui/material/ListItemText'
import Stack from '@mui/material/Stack'
import TextField from '@mui/material/TextField'
import Typography from '@mui/material/Typography'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
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'
export function MessagesPage() {
const qc = useQueryClient()
const [selectedId, setSelectedId] = useState<string | null>(null)
const [text, setText] = useState('')
const listQuery = useQuery({
queryKey: ['me', 'conversations'],
queryFn: fetchMyConversations,
})
const conversationsList = useMemo(() => listQuery.data?.items ?? [], [listQuery.data?.items])
const activeThreadId = useMemo(() => {
return selectedId ?? conversationsList[0]?.orderId ?? null
}, [selectedId, conversationsList])
const orderQuery = useQuery({
queryKey: ['me', 'orders', activeThreadId],
queryFn: () => fetchMyOrder(activeThreadId!),
enabled: Boolean(activeThreadId),
})
useEffect(() => {
if (!activeThreadId || orderQuery.status !== 'success') return
void (async () => {
await markOrderMessagesRead(activeThreadId).catch(() => undefined)
await qc.invalidateQueries({ queryKey: ['me', 'messages', 'unread-count'] })
await qc.invalidateQueries({ queryKey: ['me', 'conversations'] })
})()
}, [activeThreadId, orderQuery.status, qc])
const msgMut = useMutation({
mutationFn: () => postOrderMessage(activeThreadId!, text.trim()),
onSuccess: async () => {
setText('')
await qc.invalidateQueries({ queryKey: ['me', 'orders', activeThreadId] })
await qc.invalidateQueries({ queryKey: ['me', 'conversations'] })
},
})
const order = orderQuery.data?.item
return (
<Box>
<Typography variant="h4" gutterBottom>
Сообщения
</Typography>
<Typography color="text.secondary">Скоро здесь появятся сообщения и уведомления.</Typography>
<Typography color="text.secondary" variant="body2" sx={{ mb: 2 }}>
Переписка по всем заказам. Последнее сообщение в каждом заказе в списке слева.
</Typography>
{listQuery.isError && <Alert severity="error">Не удалось загрузить переписки.</Alert>}
{listQuery.isSuccess && conversationsList.length === 0 && (
<Alert severity="info">
Пока нет сообщений в заказах. Их отправит администратор или напишите сами на странице заказа.
</Alert>
)}
{conversationsList.length > 0 && (
<Stack direction={{ xs: 'column', md: 'row' }} spacing={2} sx={{ alignItems: 'flex-start' }}>
<Box
sx={{
width: { xs: '100%', md: 320 },
flexShrink: 0,
border: 1,
borderColor: 'divider',
borderRadius: 2,
bgcolor: 'background.paper',
maxHeight: 520,
overflow: 'auto',
}}
>
<List disablePadding>
{conversationsList.map((c) => (
<ListItem
key={c.orderId}
disablePadding
secondaryAction={
c.unreadCount > 0 ? (
<Chip sx={{ mr: 1 }} size="small" color="error" label={String(c.unreadCount)} />
) : null
}
>
<ListItemButton selected={activeThreadId === c.orderId} onClick={() => setSelectedId(c.orderId)}>
<ListItemText
primary={
<Stack direction="row" spacing={1} sx={{ alignItems: 'center', flexWrap: 'wrap' }}>
<Typography component="span" sx={{ fontWeight: 700 }}>
{c.orderId.slice(-6)}
</Typography>
<Typography component="span" variant="caption" color="text.secondary">
· {orderStatusLabelRu(c.status)}
</Typography>
</Stack>
}
secondaryTypographyProps={{
sx: {
mt: 0.5,
overflow: 'hidden',
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
},
}}
secondary={c.preview}
/>
</ListItemButton>
</ListItem>
))}
</List>
</Box>
<Box
sx={{
flexGrow: 1,
minWidth: 0,
border: 1,
borderColor: 'divider',
borderRadius: 2,
p: 2,
bgcolor: 'background.paper',
}}
>
{!activeThreadId && <Typography color="text.secondary">Выберите заказ.</Typography>}
{activeThreadId && orderQuery.isLoading && <Typography>Загрузка чата</Typography>}
{activeThreadId && orderQuery.isError && <Alert severity="error">Не удалось загрузить заказ.</Alert>}
{order && (
<>
<Stack direction="row" sx={{ mb: 2, justifyContent: 'space-between', flexWrap: 'wrap', gap: 1 }}>
<Typography variant="h6">
Чат заказа {order.id.slice(-6)}{' '}
<Typography component="span" variant="body2" color="text.secondary">
({orderStatusLabelRu(order.status)})
</Typography>
</Typography>
<Button component={RouterLink} to={`/me/orders/${order.id}`} size="small" variant="outlined">
Открыть заказ
</Button>
</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',
}}
>
<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} sx={{ alignItems: { sm: 'flex-end' } }}>
<TextField
label="Сообщение"
value={text}
onChange={(e) => setText(e.target.value)}
fullWidth
multiline
minRows={2}
/>
<Button
variant="contained"
sx={{ minWidth: 140 }}
onClick={() => msgMut.mutate()}
disabled={msgMut.isPending || !text.trim()}
>
Отправить
</Button>
</Stack>
</>
)}
</Box>
</Stack>
)}
</Box>
)
}
@@ -1,17 +1,50 @@
import { useMemo, useState } from 'react'
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 Divider from '@mui/material/Divider'
import Rating from '@mui/material/Rating'
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 axios from 'axios'
import { Link as RouterLink, useParams } from 'react-router-dom'
import { fetchMyOrder, payOrderStub, postOrderMessage } from '@/entities/order/api/order-api'
import {
confirmOrderReceived,
fetchMyOrder,
fetchOrderReviewEligibility,
payOrderStub,
postOrderMessage,
} from '@/entities/order/api/order-api'
import { postProductReview } from '@/entities/product/api/reviews-api'
import { markOrderMessagesRead } from '@/entities/user/api/messages-api'
import { formatPriceRub } from '@/shared/lib/format-price'
import { orderStatusLabelRu } from '@/shared/lib/order-status-labels'
function reviewSubmitErrorMessage(err: unknown): string {
if (axios.isAxiosError(err)) {
const status = err.response?.status
const raw = err.response?.data
const apiMsg =
raw && typeof raw === 'object' && 'error' in raw && typeof (raw as { error: unknown }).error === 'string'
? (raw as { error: string }).error
: null
if (status === 409 || apiMsg?.toLowerCase().includes('уже')) {
return 'Вы уже оставляли отзыв на этот товар.'
}
return apiMsg || err.message || 'Не удалось отправить отзыв'
}
if (err instanceof Error) return err.message
return 'Не удалось отправить отзыв'
}
type AddressSnapshot = {
deliveryType?: 'delivery' | 'pickup'
label?: string | null
recipientName?: string
recipientPhone?: string
@@ -23,6 +56,9 @@ export function OrderDetailPage() {
const { id } = useParams()
const qc = useQueryClient()
const [text, setText] = useState('')
const [reviewTarget, setReviewTarget] = useState<{ productId: string; title: string } | null>(null)
const [reviewRating, setReviewRating] = useState<number>(5)
const [reviewText, setReviewText] = useState('')
const orderQuery = useQuery({
queryKey: ['me', 'orders', id],
@@ -32,7 +68,20 @@ export function OrderDetailPage() {
const payMut = useMutation({
mutationFn: () => payOrderStub(id!),
onSuccess: () => void qc.invalidateQueries({ queryKey: ['me', 'orders', id] }),
onSuccess: () =>
Promise.all([
qc.invalidateQueries({ queryKey: ['me', 'orders', id] }),
qc.invalidateQueries({ queryKey: ['me', 'orders'] }),
]),
})
const confirmMut = useMutation({
mutationFn: () => confirmOrderReceived(id!),
onSuccess: () =>
Promise.all([
qc.invalidateQueries({ queryKey: ['me', 'orders', id] }),
qc.invalidateQueries({ queryKey: ['me', 'orders'] }),
]),
})
const msgMut = useMutation({
@@ -40,11 +89,43 @@ export function OrderDetailPage() {
onSuccess: async () => {
setText('')
await qc.invalidateQueries({ queryKey: ['me', 'orders', id] })
await qc.invalidateQueries({ queryKey: ['me', 'conversations'] })
},
})
const order = orderQuery.data?.item
const eligibilityQuery = useQuery({
queryKey: ['me', 'orders', id, 'review-eligibility'],
queryFn: () => fetchOrderReviewEligibility(id!),
enabled: Boolean(id && order?.status === 'DONE'),
})
const reviewMut = useMutation({
mutationFn: async () => {
if (!reviewTarget) return
const t = reviewText.trim()
await postProductReview(reviewTarget.productId, {
rating: reviewRating,
text: t.length ? t : null,
})
},
onSuccess: async () => {
setReviewTarget(null)
setReviewRating(5)
setReviewText('')
await qc.invalidateQueries({ queryKey: ['me', 'orders', id, 'review-eligibility'] })
},
})
useEffect(() => {
if (!id || orderQuery.status !== 'success' || !order) return
void (async () => {
await markOrderMessagesRead(id).catch(() => undefined)
await qc.invalidateQueries({ queryKey: ['me', 'messages', 'unread-count'] })
})()
}, [id, order, orderQuery.status, qc])
const address = useMemo((): AddressSnapshot | null => {
if (!order?.addressSnapshotJson) return null
try {
@@ -63,7 +144,7 @@ export function OrderDetailPage() {
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} sx={{ mb: 2, alignItems: { sm: 'center' } }}>
<Box sx={{ flexGrow: 1 }}>
<Typography variant="h4">Заказ #{order.id.slice(-6)}</Typography>
<Typography color="text.secondary">Статус: {order.status}</Typography>
<Typography color="text.secondary">Статус: {orderStatusLabelRu(order.status)}</Typography>
</Box>
<Button component={RouterLink} to="/me/orders" variant="outlined">
К списку
@@ -89,27 +170,49 @@ export function OrderDetailPage() {
))}
</Stack>
<Divider sx={{ my: 2 }} />
<Typography variant="h6">Итого: {formatPriceRub(order.totalCents)}</Typography>
<Stack spacing={0.25}>
<Typography variant="body2" color="text.secondary">
Товары: {formatPriceRub(order.itemsSubtotalCents)}
</Typography>
{order.deliveryType === 'delivery' && (
<Typography variant="body2" color="text.secondary">
Доставка: {formatPriceRub(order.deliveryFeeCents)}
</Typography>
)}
<Typography variant="h6">Итого: {formatPriceRub(order.totalCents)}</Typography>
</Stack>
</Box>
<Box sx={{ border: 1, borderColor: 'divider', borderRadius: 2, p: 2, bgcolor: 'background.paper' }}>
<Typography variant="h6" gutterBottom>
Доставка
Получение
</Typography>
{address ? (
<Typography color="text.secondary" variant="body2" sx={{ mb: 1 }}>
Способ: {order.deliveryType === 'pickup' ? 'Самовывоз' : 'Доставка'}
</Typography>
{order.deliveryType === 'delivery' && (
<>
<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>
{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>
)}
</>
) : (
<Typography color="text.secondary">Адрес не распознан.</Typography>
)}
{order.deliveryType === 'pickup' && (
<Typography color="text.secondary">
Адрес доставки не требуется. Мы свяжемся для согласования самовывоза.
</Typography>
)}
{order.comment && (
<Typography color="text.secondary" variant="body2" sx={{ mt: 1 }}>
@@ -122,14 +225,81 @@ export function OrderDetailPage() {
<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>
{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>
{(order.deliveryType === 'delivery' && order.status === 'SHIPPED') ||
(order.deliveryType === 'pickup' && order.status === 'READY_FOR_PICKUP') ? (
<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: 2 }}>
{order.deliveryType === 'delivery'
? 'Когда забрали посылку у курьера или на пункте выдачи — подтвердите получение.'
: 'Когда забрали заказ самовывозом — подтвердите получение.'}
</Typography>
<Button
variant="contained"
color="success"
onClick={() => confirmMut.mutate()}
disabled={confirmMut.isPending}
>
Подтвердить получение
</Button>
</Box>
) : null}
{order.status === 'DONE' && eligibilityQuery.isSuccess && eligibilityQuery.data.canReview && (
<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: 2 }}>
Поделитесь впечатлением о товарах. Отзывы появляются после модерации.
</Typography>
<Stack spacing={1}>
{eligibilityQuery.data.items.map((row) => (
<Stack
key={row.productId}
direction={{ xs: 'column', sm: 'row' }}
spacing={1}
sx={{ alignItems: { sm: 'center' }, justifyContent: 'space-between' }}
>
<Typography sx={{ flexGrow: 1 }}>{row.title}</Typography>
<Button
size="small"
variant="outlined"
disabled={row.hasReview}
onClick={() => setReviewTarget({ productId: row.productId, title: row.title })}
>
{row.hasReview ? 'Отзыв отправлен' : 'Оставить отзыв'}
</Button>
</Stack>
))}
</Stack>
</Box>
)}
<Box sx={{ border: 1, borderColor: 'divider', borderRadius: 2, p: 2, bgcolor: 'background.paper' }}>
<Typography variant="h6" gutterBottom>
Чат по заказу
@@ -175,6 +345,48 @@ export function OrderDetailPage() {
</Stack>
</Box>
</Stack>
<Dialog
open={Boolean(reviewTarget)}
onClose={() => !reviewMut.isPending && setReviewTarget(null)}
fullWidth
maxWidth="sm"
>
<DialogTitle>Отзыв: {reviewTarget?.title}</DialogTitle>
<DialogContent>
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
Оценка
</Typography>
<Rating
value={reviewRating}
onChange={(_, v) => {
if (v !== null) setReviewRating(v)
}}
/>
<TextField
sx={{ mt: 2 }}
label="Комментарий (необязательно)"
value={reviewText}
onChange={(e) => setReviewText(e.target.value)}
fullWidth
multiline
minRows={3}
/>
{reviewMut.isError && (
<Alert severity="error" sx={{ mt: 2 }}>
{reviewSubmitErrorMessage(reviewMut.error)}
</Alert>
)}
</DialogContent>
<DialogActions>
<Button onClick={() => setReviewTarget(null)} disabled={reviewMut.isPending}>
Отмена
</Button>
<Button variant="contained" disabled={reviewMut.isPending} onClick={() => reviewMut.mutate()}>
Отправить
</Button>
</DialogActions>
</Dialog>
</Box>
)
}
@@ -7,6 +7,7 @@ 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'
import { orderStatusLabelRu } from '@/shared/lib/order-status-labels'
export function OrdersPage() {
const ordersQuery = useQuery({
@@ -44,7 +45,7 @@ export function OrdersPage() {
<Box sx={{ flexGrow: 1 }}>
<Typography sx={{ fontWeight: 700 }}>Заказ #{o.id.slice(-6)}</Typography>
<Typography color="text.secondary" variant="body2">
Статус: {o.status} · {o.itemsCount} поз.
Статус: {orderStatusLabelRu(o.status)} · {o.itemsCount} поз.
</Typography>
</Box>
<Typography sx={{ fontWeight: 700 }}>{formatPriceRub(o.totalCents)}</Typography>
@@ -1,11 +1,16 @@
import { useMemo, useState } from 'react'
import CloseIcon from '@mui/icons-material/Close'
import StarRoundedIcon from '@mui/icons-material/StarRounded'
import Alert from '@mui/material/Alert'
import Box from '@mui/material/Box'
import Chip from '@mui/material/Chip'
import Dialog from '@mui/material/Dialog'
import Divider from '@mui/material/Divider'
import IconButton from '@mui/material/IconButton'
import Paper from '@mui/material/Paper'
import Rating from '@mui/material/Rating'
import Skeleton from '@mui/material/Skeleton'
import Stack from '@mui/material/Stack'
import Typography from '@mui/material/Typography'
import { useQuery } from '@tanstack/react-query'
import { useParams } from 'react-router-dom'
@@ -14,8 +19,10 @@ import { Swiper, SwiperSlide } from 'swiper/react'
import 'swiper/css'
import 'swiper/css/navigation'
import { fetchPublicProduct } from '@/entities/product/api/product-api'
import { fetchPublicProductReviews } from '@/entities/product/api/reviews-api'
import { ToggleCartIcon } from '@/features/cart/toggle-cart-icon'
import { formatPriceRub } from '@/shared/lib/format-price'
import { reviewsCountRu } from '@/shared/lib/reviews-count-ru'
export function ProductPage() {
const { id } = useParams()
@@ -28,6 +35,12 @@ export function ProductPage() {
enabled: Boolean(id),
})
const reviewsQuery = useQuery({
queryKey: ['products', 'public', id, 'reviews', { page: 1, pageSize: 30 }],
queryFn: () => fetchPublicProductReviews(id!, { page: 1, pageSize: 30 }),
enabled: Boolean(id),
})
const imageUrls = useMemo(() => {
const p = productQuery.data
if (!p) return []
@@ -150,6 +163,73 @@ export function ProductPage() {
) : (
<Typography color="text.secondary">Описание появится позже.</Typography>
)}
<Divider sx={{ my: 2 }} />
<Typography variant="h6" sx={{ mb: 1 }}>
Отзывы
</Typography>
{p.reviewsSummary && p.reviewsSummary.approvedReviewCount > 0 && (
<Stack direction="row" spacing={1} sx={{ alignItems: 'center', mb: 2 }}>
<Rating
value={p.reviewsSummary.avgRating ?? 0}
readOnly
precision={0.25}
icon={<StarRoundedIcon fontSize="inherit" />}
emptyIcon={<StarRoundedIcon fontSize="inherit" />}
/>
<Typography variant="body2" color="text.secondary">
{reviewsCountRu(p.reviewsSummary.approvedReviewCount)}
</Typography>
</Stack>
)}
{reviewsQuery.isLoading && <Typography color="text.secondary">Загрузка отзывов</Typography>}
{reviewsQuery.isError && <Alert severity="warning">Не удалось загрузить отзывы.</Alert>}
{reviewsQuery.data && reviewsQuery.data.total === 0 && (
<Typography color="text.secondary">Пока нет опубликованных отзывов на этот товар.</Typography>
)}
{reviewsQuery.data && reviewsQuery.data.items.length > 0 && (
<Stack spacing={1.25}>
{reviewsQuery.data.items.map((rv) => {
const body = typeof rv.text === 'string' && rv.text.trim() ? rv.text.trim() : null
return (
<Paper key={rv.id} variant="outlined" sx={{ p: 1.5, borderRadius: 2 }}>
<Stack spacing={0.75}>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={1} sx={{ justifyContent: 'space-between' }}>
<Typography sx={{ fontWeight: 700 }}>{rv.authorDisplay}</Typography>
<Typography variant="caption" color="text.secondary">
{new Date(rv.createdAt).toLocaleString('ru-RU')}
</Typography>
</Stack>
<Rating
value={rv.rating}
readOnly
size="small"
icon={<StarRoundedIcon fontSize="inherit" />}
emptyIcon={<StarRoundedIcon fontSize="inherit" />}
/>
{body ? (
<Typography variant="body2" color="text.secondary" sx={{ whiteSpace: 'pre-wrap' }}>
{body}
</Typography>
) : (
<Typography variant="caption" color="text.secondary">
Без текстового комментария.
</Typography>
)}
</Stack>
</Paper>
)
})}
{reviewsQuery.data.total > reviewsQuery.data.items.length && (
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 1 }}>
Всего {reviewsCountRu(reviewsQuery.data.total)} ниже показаны последние{' '}
{reviewsQuery.data.items.length}.
</Typography>
)}
</Stack>
)}
</Box>
<Dialog fullScreen open={viewerOpen} onClose={() => setViewerOpen(false)}>
+5
View File
@@ -2,3 +2,8 @@
export const apiBaseURL = import.meta.env.VITE_API_URL ?? '/api'
export const STORE_NAME = 'Рукодельная лавка'
/** Демо-контакты для футера; при необходимости задайте через VITE_* в `.env`. */
export const STORE_EMAIL = import.meta.env.VITE_STORE_EMAIL ?? 'hello@example.com'
export const STORE_PHONE = import.meta.env.VITE_STORE_PHONE ?? '+7 (900) 000-00-00'
export const STORE_SOCIAL_NOTE = import.meta.env.VITE_STORE_SOCIAL_NOTE ?? 'Соцсети: укажите ссылки при публикации'
+20 -11
View File
@@ -1,28 +1,37 @@
export const ORDER_STATUSES = [
'DRAFT',
'PENDING_PAYMENT',
'PAYMENT_VERIFICATION',
'PAID',
'IN_PROGRESS',
'SHIPPED',
'READY_FOR_PICKUP',
'DONE',
'CANCELLED',
] as const
export type OrderStatus = (typeof ORDER_STATUSES)[number]
export const ORDER_STATUS_TRANSITIONS: Record<OrderStatus, OrderStatus[]> = {
DRAFT: ['PENDING_PAYMENT', 'CANCELLED'],
PENDING_PAYMENT: ['PAID', 'CANCELLED'],
PAID: ['IN_PROGRESS', 'CANCELLED'],
IN_PROGRESS: ['SHIPPED', 'CANCELLED'],
SHIPPED: ['DONE'],
DONE: [],
CANCELLED: [],
/** Следующие статусы, доступные админу (смена через PATCH). */
export function getAdminNextOrderStatuses(status: string, deliveryType: 'delivery' | 'pickup'): OrderStatus[] {
switch (status) {
case 'DRAFT':
return ['PENDING_PAYMENT', 'CANCELLED']
case 'PENDING_PAYMENT':
return ['CANCELLED']
case 'PAYMENT_VERIFICATION':
return ['PAID', 'CANCELLED']
case 'PAID':
return ['IN_PROGRESS', 'CANCELLED']
case 'IN_PROGRESS':
if (deliveryType === 'delivery') return ['SHIPPED', 'CANCELLED']
return ['READY_FOR_PICKUP', 'CANCELLED']
default:
return []
}
}
export function canTransitionOrderStatus(from: string, to: string): boolean {
if (from === to) return true
const f = from as OrderStatus
const list = ORDER_STATUS_TRANSITIONS[f]
return Array.isArray(list) ? list.includes(to as OrderStatus) : false
return getAdminNextOrderStatuses(from, 'delivery').includes(to as OrderStatus)
}
+13
View File
@@ -1,13 +1,26 @@
const KEY = 'craftshop_admin_token'
const TOKEN_EVENT = 'craftshop_admin_token_change'
export function getAdminToken(): string | null {
return sessionStorage.getItem(KEY)
}
function notifyTokenListeners(): void {
window.dispatchEvent(new Event(TOKEN_EVENT))
}
/** Подписаться на смену токена (в т. ч. после setAdminToken). */
export function subscribeAdminTokenChange(cb: () => void): () => void {
window.addEventListener(TOKEN_EVENT, cb)
return () => window.removeEventListener(TOKEN_EVENT, cb)
}
export function setAdminToken(token: string): void {
sessionStorage.setItem(KEY, token)
notifyTokenListeners()
}
export function clearAdminToken(): void {
sessionStorage.removeItem(KEY)
notifyTokenListeners()
}
@@ -0,0 +1,7 @@
import { apiBaseURL } from '@/shared/config'
/** Абсолютный или корневой путь начала OAuth на бэкенде (редирект браузера). */
export function oauthAuthorizeUrl(provider: 'vk' | 'yandex'): string {
const base = apiBaseURL.replace(/\/$/, '')
return `${base}/auth/oauth/${provider}`
}
@@ -0,0 +1,15 @@
/** Человекочитаемые подписи к кодам статуса заказа */
export function orderStatusLabelRu(code: string): string {
const map: Record<string, string> = {
DRAFT: 'Черновик',
PENDING_PAYMENT: 'Ожидает оплаты',
PAYMENT_VERIFICATION: 'Проверка оплаты',
PAID: 'Оплачен',
IN_PROGRESS: 'В работе',
SHIPPED: 'Отправлен',
READY_FOR_PICKUP: 'Готово к получению',
DONE: 'Завершён',
CANCELLED: 'Отменён',
}
return map[code] ?? code
}
+12
View File
@@ -0,0 +1,12 @@
/** Склонение «N отзыв(ов…)» для целых n ≥ 0. */
export function reviewsCountRu(n: number): string {
const x = Math.abs(Math.floor(n))
const mod100 = x % 100
const mod10 = x % 10
let word = 'отзывов'
if (mod100 < 11 || mod100 > 14) {
if (mod10 === 1) word = 'отзыв'
else if (mod10 >= 2 && mod10 <= 4) word = 'отзыва'
}
return `${x}\u00a0${word}`
}
+3
View File
@@ -2,6 +2,9 @@
interface ImportMetaEnv {
readonly VITE_API_URL?: string
readonly VITE_STORE_EMAIL?: string
readonly VITE_STORE_PHONE?: string
readonly VITE_STORE_SOCIAL_NOTE?: string
}
interface ImportMeta {
@@ -0,0 +1 @@
export { ReviewsBlock } from './ui/ReviewsBlock'
@@ -0,0 +1,119 @@
import StarRoundedIcon from '@mui/icons-material/StarRounded'
import Alert from '@mui/material/Alert'
import Avatar from '@mui/material/Avatar'
import Box from '@mui/material/Box'
import Paper from '@mui/material/Paper'
import Rating from '@mui/material/Rating'
import Skeleton from '@mui/material/Skeleton'
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 { fetchLatestApprovedReviews } from '@/entities/product/api/reviews-api'
function initials(display: string) {
const s = display.trim()
if (!s) return '?'
return s.slice(0, 1).toUpperCase()
}
function formatReviewDate(iso: string): string {
try {
return new Date(iso).toLocaleDateString('ru-RU', { day: '2-digit', month: '2-digit', year: 'numeric' })
} catch {
return ''
}
}
export function ReviewsBlock() {
const q = useQuery({
queryKey: ['reviews', 'latest', 5],
queryFn: () => fetchLatestApprovedReviews(5),
})
const items = !q.isLoading && !q.isError && q.data ? q.data.items : []
return (
<Paper variant="outlined" sx={{ p: { xs: 2, sm: 3 }, borderRadius: 2, bgcolor: 'background.paper' }}>
<Stack spacing={0.75} sx={{ mb: 2 }}>
<Typography variant="h5">Отзывы</Typography>
<Typography variant="body2" color="text.secondary">
Последние одобренные отзывы о товарах
</Typography>
</Stack>
{q.isLoading && (
<Stack spacing={2}>
{[1, 2, 3].map((i) => (
<Skeleton key={i} variant="rounded" height={92} sx={{ borderRadius: 2 }} />
))}
</Stack>
)}
{q.isError && <Alert severity="error">Не удалось загрузить отзывы.</Alert>}
{!q.isLoading && !q.isError && q.data && items.length === 0 && (
<Typography color="text.secondary">Пока нет опубликованных отзывов о товарах.</Typography>
)}
{items.length > 0 && (
<Stack spacing={2}>
{items.map((r, i) => {
const zebra = i % 2 === 0
const text = typeof r.text === 'string' && r.text.trim() ? r.text.trim() : 'Без комментария'
return (
<Paper
key={r.id}
variant={zebra ? undefined : 'outlined'}
elevation={zebra ? 0 : undefined}
sx={{
p: { xs: 1.75, sm: 2 },
borderRadius: 2,
...(zebra ? { bgcolor: 'action.hover' } : {}),
}}
>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2}>
<Stack direction="row" spacing={1.5} sx={{ minWidth: { sm: 220 }, alignItems: 'center' }}>
<Avatar sx={{ bgcolor: 'primary.main', color: 'primary.contrastText', fontWeight: 800 }}>
{initials(r.authorDisplay)}
</Avatar>
<Box>
<Typography sx={{ fontWeight: 800, lineHeight: 1.15 }}>{r.authorDisplay}</Typography>
<Stack direction="row" spacing={1} sx={{ alignItems: 'center', flexWrap: 'wrap' }}>
<Rating
value={r.rating}
readOnly
size="small"
icon={<StarRoundedIcon fontSize="inherit" />}
emptyIcon={<StarRoundedIcon fontSize="inherit" />}
/>
<Typography variant="caption" color="text.secondary">
{formatReviewDate(r.createdAt)}
</Typography>
</Stack>
<Typography
variant="caption"
component={RouterLink}
to={`/products/${r.productId}`}
sx={{
display: 'block',
mt: 0.25,
color: 'primary.main',
textDecoration: 'none',
'&:hover': { textDecoration: 'underline' },
}}
>
{r.productTitle}
</Typography>
</Box>
</Stack>
<Typography color="text.secondary" sx={{ whiteSpace: 'pre-wrap', flex: 1 }}>
{text}
</Typography>
</Stack>
</Paper>
)
})}
</Stack>
)}
</Paper>
)
}