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
+18
View File
@@ -81,6 +81,24 @@ npm run dev
Для боевого размещения фронта и API на разных доменах задайте `VITE_API_URL` (например `https://api.example.com/api`) и **CORS_ORIGIN** на сервере. Для боевого размещения фронта и API на разных доменах задайте `VITE_API_URL` (например `https://api.example.com/api`) и **CORS_ORIGIN** на сервере.
### OAuth VK и Яндекс
В `server/.env` задайте `VK_CLIENT_ID`, `VK_CLIENT_SECRET`, `YANDEX_CLIENT_ID`, `YANDEX_CLIENT_SECRET`, а также **точные** публичные адреса:
- `SERVER_PUBLIC_URL` — базовый URL API (без завершающего `/`), например `https://api.example.com` или `http://127.0.0.1:3333`.
- `CLIENT_PUBLIC_URL` — базовый URL витрины, куда бэкенд редиректит после входа с JWT в query: `/auth/callback?token=...`, например `http://127.0.0.1:5173`.
**Redirect URI в кабинетах провайдеров** (должны совпадать с тем, что шлёт сервер при авторизации):
- VK: `{SERVER_PUBLIC_URL}/api/auth/oauth/vk/callback`
- Яндекс: `{SERVER_PUBLIC_URL}/api/auth/oauth/yandex/callback`
Старт входа с витрины: кнопки на странице `/auth` ведут на `GET /api/auth/oauth/vk` и `GET /api/auth/oauth/yandex` (полный URL — тот же origin, что и API: при прокси Vite это `/api/...` относительно фронта; при отдельном домене API — из `VITE_API_URL`).
### Футер витрины (опционально)
В `client/.env` можно задать `VITE_STORE_EMAIL`, `VITE_STORE_PHONE`, `VITE_STORE_SOCIAL_NOTE` для блока контактов в подвале.
## API (кратко) ## API (кратко)
Публичные: Публичные:
+2 -1
View File
@@ -2,7 +2,7 @@ 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 { AdminLayoutPage } from '@/pages/admin-layout' import { AdminLayoutPage } from '@/pages/admin-layout'
import { AuthPage } from '@/pages/auth' import { AuthCallbackPage, AuthPage } from '@/pages/auth'
import { CartPage } from '@/pages/cart' import { CartPage } from '@/pages/cart'
import { CheckoutPage } from '@/pages/checkout' import { CheckoutPage } from '@/pages/checkout'
import { HomePage } from '@/pages/home' import { HomePage } from '@/pages/home'
@@ -18,6 +18,7 @@ export function App() {
<Route path="/" element={<HomePage />} /> <Route path="/" element={<HomePage />} />
<Route path="/admin/*" element={<AdminLayoutPage />} /> <Route path="/admin/*" element={<AdminLayoutPage />} />
<Route path="/auth" element={<AuthPage />} /> <Route path="/auth" element={<AuthPage />} />
<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="/me/*" element={<MeLayoutPage />} /> <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)' }, '& .MuiInputLabel-root.MuiInputLabel-shrink': { color: 'rgba(255,255,255,0.92)' },
}} }}
> >
<InputLabel id="scheme-label">Тема</InputLabel> <InputLabel id="scheme-label">Схема</InputLabel>
<Select <Select
labelId="scheme-label" labelId="scheme-label"
value={scheme} value={scheme}
label="Тема" label="Схема"
onChange={onSchemeChange} onChange={onSchemeChange}
sx={{ sx={{
color: 'inherit', color: 'inherit',
@@ -93,11 +93,11 @@ function ThemeControlsDesktop(props: {
'& .MuiInputLabel-root.MuiInputLabel-shrink': { color: 'rgba(255,255,255,0.92)' }, '& .MuiInputLabel-root.MuiInputLabel-shrink': { color: 'rgba(255,255,255,0.92)' },
}} }}
> >
<InputLabel id="mode-label">Режим</InputLabel> <InputLabel id="mode-label">Тема</InputLabel>
<Select <Select
labelId="mode-label" labelId="mode-label"
value={mode} value={mode}
label="Режим" label="Тема"
onChange={onModeChange} onChange={onModeChange}
sx={{ sx={{
color: 'inherit', color: 'inherit',
@@ -139,8 +139,8 @@ function ThemeControlsMobile(props: {
return ( return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}> <Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<FormControl size="small" fullWidth> <FormControl size="small" fullWidth>
<InputLabel id="scheme-label-mobile">Тема</InputLabel> <InputLabel id="scheme-label-mobile">Схема</InputLabel>
<Select labelId="scheme-label-mobile" value={scheme} label="Тема" onChange={onSchemeChange}> <Select labelId="scheme-label-mobile" value={scheme} label="Схема" onChange={onSchemeChange}>
<MenuItem value="craft">Крафт</MenuItem> <MenuItem value="craft">Крафт</MenuItem>
<MenuItem value="forest">Лес</MenuItem> <MenuItem value="forest">Лес</MenuItem>
<MenuItem value="ocean">Океан</MenuItem> <MenuItem value="ocean">Океан</MenuItem>
@@ -149,8 +149,8 @@ function ThemeControlsMobile(props: {
</FormControl> </FormControl>
<FormControl size="small" fullWidth> <FormControl size="small" fullWidth>
<InputLabel id="mode-label-mobile">Режим</InputLabel> <InputLabel id="mode-label-mobile">Тема</InputLabel>
<Select labelId="mode-label-mobile" value={mode} label="Режим" onChange={onModeChange}> <Select labelId="mode-label-mobile" value={mode} label="Тема" onChange={onModeChange}>
<MenuItem value="system">Авто (система)</MenuItem> <MenuItem value="system">Авто (система)</MenuItem>
<MenuItem value="light">Светлая</MenuItem> <MenuItem value="light">Светлая</MenuItem>
<MenuItem value="dark">Тёмная</MenuItem> <MenuItem value="dark">Тёмная</MenuItem>
+71 -4
View File
@@ -1,10 +1,18 @@
import { type PropsWithChildren } from 'react' import { type PropsWithChildren } from 'react'
import Box from '@mui/material/Box' import Box from '@mui/material/Box'
import Container from '@mui/material/Container' 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 Typography from '@mui/material/Typography'
import { Link as RouterLink } from 'react-router-dom'
import { AppHeader } from '@/app/layout/AppHeader' import { AppHeader } from '@/app/layout/AppHeader'
import { STORE_EMAIL, STORE_NAME, STORE_PHONE, STORE_SOCIAL_NOTE } from '@/shared/config'
export function MainLayout({ children }: PropsWithChildren) { export function MainLayout({ children }: PropsWithChildren) {
const year = new Date().getFullYear()
return ( return (
<Box sx={{ display: 'flex', flexDirection: 'column', minHeight: '100vh' }}> <Box sx={{ display: 'flex', flexDirection: 'column', minHeight: '100vh' }}>
<AppHeader /> <AppHeader />
@@ -12,17 +20,76 @@ export function MainLayout({ children }: PropsWithChildren) {
<Box component="main" sx={{ flex: 1, py: 3 }}> <Box component="main" sx={{ flex: 1, py: 3 }}>
<Container maxWidth="lg">{children}</Container> <Container maxWidth="lg">{children}</Container>
</Box> </Box>
<Box <Box
component="footer" component="footer"
sx={{ sx={{
py: 2, mt: 'auto',
textAlign: 'center',
borderTop: 1, borderTop: 1,
borderColor: 'divider', 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>
</Box> </Box>
) )
@@ -3,6 +3,7 @@ import { apiClient } from '@/shared/api/client'
export type AdminOrderListItem = { export type AdminOrderListItem = {
id: string id: string
status: string status: string
deliveryType: 'delivery' | 'pickup'
totalCents: number totalCents: number
currency: string currency: string
createdAt: string createdAt: string
@@ -22,9 +23,12 @@ export type AdminOrderDetailResponse = {
item: { item: {
id: string id: string
status: string status: string
deliveryType: 'delivery' | 'pickup'
itemsSubtotalCents: number
deliveryFeeCents: number
totalCents: number totalCents: number
currency: string currency: string
addressSnapshotJson: string addressSnapshotJson: string | null
comment: string | null comment: string | null
createdAt: string createdAt: string
updatedAt: 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( export async function fetchAdminOrders(
token: string, 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> { ): Promise<AdminOrdersListResponse> {
const { data } = await apiClient.get<AdminOrdersListResponse>('admin/orders', { const { data } = await apiClient.get<AdminOrdersListResponse>('admin/orders', {
params, params,
+29 -4
View File
@@ -16,9 +16,12 @@ export type OrderDetailResponse = {
item: { item: {
id: string id: string
status: string status: string
deliveryType: 'delivery' | 'pickup'
itemsSubtotalCents: number
deliveryFeeCents: number
totalCents: number totalCents: number
currency: string currency: string
addressSnapshotJson: string addressSnapshotJson: string | null
comment: string | null comment: string | null
createdAt: string createdAt: string
updatedAt: 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) const { data } = await apiClient.post<{ orderId: string }>('me/orders', body)
return data return data
} }
@@ -57,6 +64,24 @@ export async function postOrderMessage(id: string, text: string): Promise<void>
await apiClient.post(`me/orders/${id}/messages`, { text }) await apiClient.post(`me/orders/${id}/messages`, { text })
} }
export async function payOrderStub(id: string): Promise<void> { export async function payOrderStub(id: string): Promise<{ ok: boolean; status: string }> {
await apiClient.post(`me/orders/${id}/pay`) 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 categorySlug?: string
q?: string q?: string
sort?: 'price_asc' | 'price_desc' | '' sort?: 'price_asc' | 'price_desc' | ''
availability?: 'all' | 'in_stock' | 'made_to_order'
page?: number page?: number
pageSize?: number pageSize?: number
priceMinCents?: number priceMinCents?: number
@@ -22,6 +23,7 @@ export async function fetchPublicProducts(params?: {
categorySlug: params?.categorySlug || undefined, categorySlug: params?.categorySlug || undefined,
q: params?.q || undefined, q: params?.q || undefined,
sort: params?.sort || undefined, sort: params?.sort || undefined,
availability: params?.availability || undefined,
page: params?.page || undefined, page: params?.page || undefined,
pageSize: params?.pageSize || undefined, pageSize: params?.pageSize || undefined,
priceMin: params?.priceMinCents ?? 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 sort: number
} }
export type ProductReviewsSummary = {
approvedReviewCount: number
avgRating: number | null
latestApprovedText: string | null
}
export type Product = { export type Product = {
id: string id: string
title: string title: string
@@ -24,4 +30,6 @@ export type Product = {
updatedAt: string updatedAt: string
category?: Category category?: Category
images?: { id: string; url: string; sort: number }[] 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 CardMedia from '@mui/material/CardMedia'
import Chip from '@mui/material/Chip' import Chip from '@mui/material/Chip'
import Link from '@mui/material/Link' import Link from '@mui/material/Link'
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'
import { Link as RouterLink } from 'react-router-dom' import { Link as RouterLink } from 'react-router-dom'
@@ -13,6 +14,7 @@ import { Swiper, SwiperSlide } from 'swiper/react'
import 'swiper/css' import 'swiper/css'
import type { Product } from '@/entities/product/model/types' import type { Product } from '@/entities/product/model/types'
import { formatPriceRub } from '@/shared/lib/format-price' import { formatPriceRub } from '@/shared/lib/format-price'
import { reviewsCountRu } from '@/shared/lib/reviews-count-ru'
import type { Swiper as SwiperType } from 'swiper/types' import type { Swiper as SwiperType } from 'swiper/types'
type Props = { product: Product; mediaHeight?: number; actions?: ReactNode } type Props = { product: Product; mediaHeight?: number; actions?: ReactNode }
@@ -117,6 +119,7 @@ export function ProductCard({ product, mediaHeight = 200, actions }: Props) {
<CardContent sx={{ flexGrow: 1 }}> <CardContent sx={{ flexGrow: 1 }}>
<Stack spacing={1}> <Stack spacing={1}>
{product.category && <Chip label={product.category.name} size="small" />} {product.category && <Chip label={product.category.name} size="small" />}
{product.inStock && product.quantity === 0 && <Chip label="Нет в наличии" size="small" color="default" />}
{!product.inStock && ( {!product.inStock && (
<Chip <Chip
label={`Под заказ · ${product.leadTimeDays ?? '—'} дн.`} label={`Под заказ · ${product.leadTimeDays ?? '—'} дн.`}
@@ -157,6 +160,38 @@ export function ProductCard({ product, mediaHeight = 200, actions }: Props) {
<Typography variant="h6" color="primary"> <Typography variant="h6" color="primary">
{formatPriceRub(product.priceCents)} {formatPriceRub(product.priceCents)}
</Typography> </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} {actions}
</Stack> </Stack>
</CardContent> </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 { addToCart, fetchMyCart, removeCartItem } from '@/entities/cart/api/cart-api'
import { $user } from '@/shared/model/auth' import { $user } from '@/shared/model/auth'
export function ToggleCartIcon(props: { productId: string; size?: 'small' | 'medium' }) { export function ToggleCartIcon(props: {
const { productId, size = 'small' } = props productId: string
size?: 'small' | 'medium'
disabledReason?: string | null
}) {
const { productId, size = 'small', disabledReason = null } = props
const user = useUnit($user) const user = useUnit($user)
const qc = useQueryClient() const qc = useQueryClient()
const navigate = useNavigate() const navigate = useNavigate()
@@ -34,12 +38,13 @@ export function ToggleCartIcon(props: { productId: string; size?: 'small' | 'med
onSuccess: () => void qc.invalidateQueries({ queryKey: ['me', 'cart'] }), onSuccess: () => void qc.invalidateQueries({ queryKey: ['me', 'cart'] }),
}) })
const disabled = !user const disabled = !user || Boolean(disabledReason)
const busy = addMut.isPending || removeMut.isPending const busy = addMut.isPending || removeMut.isPending
const onClick = (e: React.MouseEvent) => { const onClick = (e: React.MouseEvent) => {
e.preventDefault() e.preventDefault()
e.stopPropagation() e.stopPropagation()
if (disabledReason) return
if (!user) { if (!user) {
navigate('/auth') navigate('/auth')
return return
@@ -48,7 +53,13 @@ export function ToggleCartIcon(props: { productId: string; size?: 'small' | 'med
else addMut.mutate() else addMut.mutate()
} }
const tooltip = !user ? 'Авторизуйтесь для совершения покупок' : inCart ? 'Убрать из корзины' : 'В корзину' const tooltip = disabledReason
? disabledReason
: !user
? 'Авторизуйтесь для совершения покупок'
: inCart
? 'Убрать из корзины'
: 'В корзину'
return ( return (
<Tooltip title={tooltip}> <Tooltip title={tooltip}>
@@ -1,10 +1,11 @@
import type { ReactNode } from 'react' import type { ReactNode } from 'react'
import { useMemo, useState } from 'react' import { useMemo, useState, useSyncExternalStore } from 'react'
import AssignmentOutlinedIcon from '@mui/icons-material/AssignmentOutlined' import AssignmentOutlinedIcon from '@mui/icons-material/AssignmentOutlined'
import MenuOutlinedIcon from '@mui/icons-material/MenuOutlined' import MenuOutlinedIcon from '@mui/icons-material/MenuOutlined'
import PeopleOutlinedIcon from '@mui/icons-material/PeopleOutlined' import PeopleOutlinedIcon from '@mui/icons-material/PeopleOutlined'
import RateReviewOutlinedIcon from '@mui/icons-material/RateReviewOutlined' import RateReviewOutlinedIcon from '@mui/icons-material/RateReviewOutlined'
import StorefrontOutlinedIcon from '@mui/icons-material/StorefrontOutlined' import StorefrontOutlinedIcon from '@mui/icons-material/StorefrontOutlined'
import Badge from '@mui/material/Badge'
import Box from '@mui/material/Box' import Box from '@mui/material/Box'
import Divider from '@mui/material/Divider' import Divider from '@mui/material/Divider'
import Drawer from '@mui/material/Drawer' import Drawer from '@mui/material/Drawer'
@@ -17,11 +18,14 @@ import Stack from '@mui/material/Stack'
import { useTheme } from '@mui/material/styles' import { useTheme } from '@mui/material/styles'
import Typography from '@mui/material/Typography' import Typography from '@mui/material/Typography'
import useMediaQuery from '@mui/material/useMediaQuery' import useMediaQuery from '@mui/material/useMediaQuery'
import { useQuery } from '@tanstack/react-query'
import { Navigate, Route, Routes, useLocation, useNavigate } from 'react-router-dom' 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 { AdminPage } from '@/pages/admin'
import { AdminOrdersPage } from '@/pages/admin-orders' import { AdminOrdersPage } from '@/pages/admin-orders'
import { AdminReviewsPage } from '@/pages/admin-reviews' import { AdminReviewsPage } from '@/pages/admin-reviews'
import { AdminUsersPage } from '@/pages/admin-users' import { AdminUsersPage } from '@/pages/admin-users'
import { getAdminToken, subscribeAdminTokenChange } from '@/shared/lib/admin-token'
type NavItem = { type NavItem = {
to: string to: string
@@ -35,6 +39,17 @@ export function AdminLayoutPage() {
const theme = useTheme() const theme = useTheme()
const isMobile = useMediaQuery(theme.breakpoints.down('md')) const isMobile = useMediaQuery(theme.breakpoints.down('md'))
const [mobileOpen, setMobileOpen] = useState(false) 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( const navItems: NavItem[] = useMemo(
() => [ () => [
@@ -72,7 +87,15 @@ export function AdminLayoutPage() {
setMobileOpen(false) 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} /> <ListItemText primary={i.label} />
</ListItemButton> </ListItemButton>
))} ))}
@@ -26,9 +26,10 @@ import {
postAdminOrderMessage, postAdminOrderMessage,
setAdminOrderStatus, setAdminOrderStatus,
} from '@/entities/order/api/admin-order-api' } 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 { clearAdminToken, getAdminToken, setAdminToken } from '@/shared/lib/admin-token'
import { formatPriceRub } from '@/shared/lib/format-price' import { formatPriceRub } from '@/shared/lib/format-price'
import { orderStatusLabelRu } from '@/shared/lib/order-status-labels'
type TokenFormState = { token: string } type TokenFormState = { token: string }
@@ -37,6 +38,7 @@ export function AdminOrdersPage() {
const [token, setTokenState] = useState<string | null>(() => getAdminToken()) const [token, setTokenState] = useState<string | null>(() => getAdminToken())
const [q, setQ] = useState('') const [q, setQ] = useState('')
const [status, setStatus] = useState('') const [status, setStatus] = useState('')
const [deliveryType, setDeliveryType] = useState<'delivery' | 'pickup' | ''>('')
const [dialogOpen, setDialogOpen] = useState(false) const [dialogOpen, setDialogOpen] = useState(false)
const [selectedId, setSelectedId] = useState<string | null>(null) const [selectedId, setSelectedId] = useState<string | null>(null)
const [msg, setMsg] = useState('') const [msg, setMsg] = useState('')
@@ -59,8 +61,13 @@ export function AdminOrdersPage() {
} }
const ordersQuery = useQuery({ const ordersQuery = useQuery({
queryKey: ['admin', 'orders', token, { q, status }], queryKey: ['admin', 'orders', token, { q, status, deliveryType }],
queryFn: () => fetchAdminOrders(token!, { q: q.trim() || undefined, status: status || undefined }), queryFn: () =>
fetchAdminOrders(token!, {
q: q.trim() || undefined,
status: status || undefined,
deliveryType: deliveryType || undefined,
}),
enabled: Boolean(token), enabled: Boolean(token),
}) })
@@ -75,6 +82,7 @@ export function AdminOrdersPage() {
onSuccess: async () => { onSuccess: async () => {
await qc.invalidateQueries({ queryKey: ['admin', 'orders'] }) await qc.invalidateQueries({ queryKey: ['admin', 'orders'] })
await qc.invalidateQueries({ queryKey: ['admin', 'orders', 'detail'] }) 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 detail = orderDetailQuery.data?.item
const nextStatuses = useMemo(() => { const nextStatuses = useMemo(() => {
const s = detail?.status if (!detail) return []
if (!s) return [] return getAdminNextOrderStatuses(detail.status, detail.deliveryType ?? 'delivery')
return ORDER_STATUS_TRANSITIONS[s as OrderStatus] ?? [] }, [detail])
}, [detail?.status])
return ( return (
<Box> <Box>
@@ -153,13 +160,31 @@ export function AdminOrdersPage() {
<MenuItem value=""> <MenuItem value="">
<em>Все</em> <em>Все</em>
</MenuItem> </MenuItem>
{Object.keys(ORDER_STATUS_TRANSITIONS).map((s) => ( {ORDER_STATUSES.map((s) => (
<MenuItem key={s} value={s}> <MenuItem key={s} value={s}>
{s} {orderStatusLabelRu(s)}
</MenuItem> </MenuItem>
))} ))}
</Select> </Select>
</FormControl> </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> </Stack>
{ordersQuery.isError && <Alert severity="error">Не удалось загрузить заказы.</Alert>} {ordersQuery.isError && <Alert severity="error">Не удалось загрузить заказы.</Alert>}
@@ -180,7 +205,7 @@ export function AdminOrdersPage() {
<TableRow key={o.id} hover> <TableRow key={o.id} hover>
<TableCell>{o.id.slice(-8)}</TableCell> <TableCell>{o.id.slice(-8)}</TableCell>
<TableCell>{o.user.email}</TableCell> <TableCell>{o.user.email}</TableCell>
<TableCell>{o.status}</TableCell> <TableCell>{orderStatusLabelRu(o.status)}</TableCell>
<TableCell>{formatPriceRub(o.totalCents)}</TableCell> <TableCell>{formatPriceRub(o.totalCents)}</TableCell>
<TableCell>{o.itemsCount}</TableCell> <TableCell>{o.itemsCount}</TableCell>
<TableCell align="right"> <TableCell align="right">
@@ -210,7 +235,8 @@ export function AdminOrdersPage() {
{detail && ( {detail && (
<Stack spacing={2} sx={{ mt: 1 }}> <Stack spacing={2} sx={{ mt: 1 }}>
<Typography sx={{ fontWeight: 700 }}> <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> </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' } }}>
@@ -232,7 +258,7 @@ export function AdminOrdersPage() {
</MenuItem> </MenuItem>
{nextStatuses.map((s) => ( {nextStatuses.map((s) => (
<MenuItem key={s} value={s}> <MenuItem key={s} value={s}>
{s} {orderStatusLabelRu(s)}
</MenuItem> </MenuItem>
))} ))}
</Select> </Select>
+1
View File
@@ -1 +1,2 @@
export { AuthCallbackPage } from './ui/AuthCallbackPage'
export { AuthPage } from './ui/AuthPage' 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 { useMutation } from '@tanstack/react-query'
import { useUnit } from 'effector-react' import { useUnit } from 'effector-react'
import { useForm } from 'react-hook-form' 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 { 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 } }
@@ -26,6 +27,8 @@ function getApiErrorMessage(err: unknown): string | null {
export function AuthPage() { export function AuthPage() {
const [message, setMessage] = useState<string | null>(null) const [message, setMessage] = useState<string | null>(null)
const [oauthError, setOauthError] = useState<string | null>(null)
const [searchParams, setSearchParams] = useSearchParams()
const navigate = useNavigate() const navigate = useNavigate()
const user = useUnit($user) const user = useUnit($user)
const { register, watch } = useForm<{ const { register, watch } = useForm<{
@@ -45,6 +48,13 @@ export function AuthPage() {
if (user) navigate('/', { replace: true }) if (user) navigate('/', { replace: true })
}, [navigate, user]) }, [navigate, user])
useEffect(() => {
const err = searchParams.get('oauthError')
if (!err) return
setOauthError(err)
setSearchParams({}, { replace: true })
}, [searchParams, setSearchParams])
const requestCode = useMutation({ const requestCode = useMutation({
mutationFn: async () => { mutationFn: async () => {
await apiClient.post('auth/request-code', { email }) await apiClient.post('auth/request-code', { email })
@@ -94,6 +104,11 @@ export function AuthPage() {
{message} {message}
</Alert> </Alert>
)} )}
{oauthError && (
<Alert severity="error" sx={{ mb: 2 }} onClose={() => setOauthError(null)}>
{oauthError}
</Alert>
)}
{errMsg && ( {errMsg && (
<Alert severity="error" sx={{ mb: 2 }}> <Alert severity="error" sx={{ mb: 2 }}>
{errMsg} {errMsg}
@@ -101,6 +116,18 @@ 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">Вариант 1: Email + код</Typography>
+79 -22
View File
@@ -22,6 +22,7 @@ export function CheckoutPage() {
const user = useUnit($user) const user = useUnit($user)
const qc = useQueryClient() const qc = useQueryClient()
const navigate = useNavigate() const navigate = useNavigate()
const [deliveryType, setDeliveryType] = useState<'delivery' | 'pickup'>('delivery')
const [addressId, setAddressId] = useState('') const [addressId, setAddressId] = useState('')
const [comment, setComment] = useState('') const [comment, setComment] = useState('')
@@ -41,7 +42,12 @@ export function CheckoutPage() {
const selectedAddressId = addressId || defaultAddressId const selectedAddressId = addressId || defaultAddressId
const createMut = useMutation({ 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) => { onSuccess: async (res) => {
await qc.invalidateQueries({ queryKey: ['me', 'cart'] }) await qc.invalidateQueries({ queryKey: ['me', 'cart'] })
navigate(`/me/orders/${res.orderId}`, { replace: true }) navigate(`/me/orders/${res.orderId}`, { replace: true })
@@ -61,7 +67,11 @@ export function CheckoutPage() {
} }
const items = cartQuery.data?.items ?? [] 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 addresses = addressesQuery.data?.items ?? []
const hasOverLimit = items.some((x) => x.qty > (x.product.inStock ? x.product.quantity : 1)) const hasOverLimit = items.some((x) => x.qty > (x.product.inStock ? x.product.quantity : 1))
const hasMadeToOrder = items.some((x) => !x.product.inStock) const hasMadeToOrder = items.some((x) => !x.product.inStock)
@@ -117,29 +127,63 @@ export function CheckoutPage() {
)} )}
<FormControl size="small" fullWidth> <FormControl size="small" fullWidth>
<InputLabel id="addr-label">Адрес доставки</InputLabel> <InputLabel id="delivery-type-label">Способ получения</InputLabel>
<Select <Select
labelId="addr-label" labelId="delivery-type-label"
label="Адрес доставки" label="Способ получения"
value={selectedAddressId} value={deliveryType}
onChange={(e) => setAddressId(String(e.target.value))} onChange={(e) => {
const v = String(e.target.value)
if (v === 'delivery' || v === 'pickup') setDeliveryType(v)
}}
> >
{addresses.map((a) => ( <MenuItem value="delivery">Доставка</MenuItem>
<MenuItem key={a.id} value={a.id}> <MenuItem value="pickup">Самовывоз</MenuItem>
{(a.label?.trim() ? `${a.label}: ` : '') + a.addressLine}
</MenuItem>
))}
</Select> </Select>
</FormControl> </FormControl>
{addresses.length === 0 && ( {deliveryType === 'delivery' && (
<Alert severity="warning"> <>
У вас нет адресов доставки. Добавьте адрес в{' '} <FormControl size="small" fullWidth>
<Typography component={RouterLink} to="/me/addresses" sx={{ textDecoration: 'underline' }}> <InputLabel id="addr-label">Адрес доставки</InputLabel>
кабинете <Select
</Typography> labelId="addr-label"
. label="Адрес доставки"
</Alert> 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 <TextField
@@ -151,12 +195,25 @@ export function CheckoutPage() {
minRows={2} 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 <Button
variant="contained" variant="contained"
disabled={ 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()} 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 { fetchCategories, fetchPublicProducts } from '@/entities/product/api/product-api'
import { ProductCard } from '@/entities/product/ui/ProductCard' import { ProductCard } from '@/entities/product/ui/ProductCard'
import { ToggleCartIcon } from '@/features/cart/toggle-cart-icon' import { ToggleCartIcon } from '@/features/cart/toggle-cart-icon'
import { ReviewsBlock } from '@/widgets/reviews-block'
export function HomePage() { export function HomePage() {
const [categorySlug, setCategorySlug] = useState<string>('') const [categorySlug, setCategorySlug] = useState<string>('')
const [availability, setAvailability] = useState<'all' | 'in_stock' | 'made_to_order'>('all')
const [qInput, setQInput] = useState('') const [qInput, setQInput] = useState('')
const [q, setQ] = useState('') const [q, setQ] = useState('')
const [moreOpen, setMoreOpen] = useState(false) const [moreOpen, setMoreOpen] = useState(false)
@@ -54,6 +56,7 @@ export function HomePage() {
'public', 'public',
{ {
categorySlug: categorySlug || 'all', categorySlug: categorySlug || 'all',
availability,
q, q,
sort, sort,
page, page,
@@ -69,6 +72,7 @@ export function HomePage() {
} }
return fetchPublicProducts({ return fetchPublicProducts({
categorySlug: categorySlug || undefined, categorySlug: categorySlug || undefined,
availability: availability === 'all' ? undefined : availability,
q: q || undefined, q: q || undefined,
sort: sort || '', sort: sort || '',
page, page,
@@ -155,6 +159,52 @@ export function HomePage() {
/> />
</Stack> </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 <Stack
direction={{ xs: 'column', sm: 'row' }} direction={{ xs: 'column', sm: 'row' }}
spacing={1.5} spacing={1.5}
@@ -167,6 +217,7 @@ export function HomePage() {
variant="outlined" variant="outlined"
onClick={() => { onClick={() => {
setCategorySlug('') setCategorySlug('')
setAvailability('all')
setQInput('') setQInput('')
setSort('') setSort('')
setPriceMinRub('') setPriceMinRub('')
@@ -312,22 +363,37 @@ export function HomePage() {
<Grid container spacing={2}> <Grid container spacing={2}>
{products.map((p) => ( {products.map((p) => (
<Grid size={{ xs: 12, sm: 6, md: 4 }} key={p.id}> <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>
))} ))}
</Grid> </Grid>
<Stack direction="row" sx={{ mt: 3, justifyContent: 'center' }}> {totalPages > 1 && (
<Pagination <Stack direction="row" sx={{ mt: 3, justifyContent: 'center' }}>
page={page} <Pagination
count={totalPages} page={page}
onChange={(_, v) => setPage(v)} count={totalPages}
color="primary" onChange={(_, v) => setPage(v)}
shape="rounded" color="primary"
showFirstButton shape="rounded"
showLastButton showFirstButton
/> showLastButton
</Stack> />
</Stack>
)}
<Box sx={{ mt: 4 }}>
<ReviewsBlock />
</Box>
</> </>
)} )}
</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 PlaceOutlinedIcon from '@mui/icons-material/PlaceOutlined'
import SettingsOutlinedIcon from '@mui/icons-material/SettingsOutlined' import SettingsOutlinedIcon from '@mui/icons-material/SettingsOutlined'
import Alert from '@mui/material/Alert' import Alert from '@mui/material/Alert'
import Badge from '@mui/material/Badge'
import Box from '@mui/material/Box' import Box from '@mui/material/Box'
import Divider from '@mui/material/Divider' import Divider from '@mui/material/Divider'
import Drawer from '@mui/material/Drawer' import Drawer from '@mui/material/Drawer'
@@ -18,8 +19,10 @@ import Stack from '@mui/material/Stack'
import { useTheme } from '@mui/material/styles' import { useTheme } from '@mui/material/styles'
import Typography from '@mui/material/Typography' import Typography from '@mui/material/Typography'
import useMediaQuery from '@mui/material/useMediaQuery' import useMediaQuery from '@mui/material/useMediaQuery'
import { useQuery } from '@tanstack/react-query'
import { useUnit } from 'effector-react' import { useUnit } from 'effector-react'
import { Navigate, Route, Routes, useLocation, useNavigate } from 'react-router-dom' 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 { AddressesPage } from '@/pages/me/ui/sections/AddressesPage'
import { MessagesPage } from '@/pages/me/ui/sections/MessagesPage' import { MessagesPage } from '@/pages/me/ui/sections/MessagesPage'
import { OrderDetailPage } from '@/pages/me/ui/sections/OrderDetailPage' import { OrderDetailPage } from '@/pages/me/ui/sections/OrderDetailPage'
@@ -41,6 +44,16 @@ export function MeLayoutPage() {
const isMobile = useMediaQuery(theme.breakpoints.down('md')) const isMobile = useMediaQuery(theme.breakpoints.down('md'))
const [mobileOpen, setMobileOpen] = useState(false) 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( const navItems: NavItem[] = useMemo(
() => [ () => [
{ to: '/me/orders', label: 'Заказы', icon: <LocalShippingOutlinedIcon /> }, { to: '/me/orders', label: 'Заказы', icon: <LocalShippingOutlinedIcon /> },
@@ -81,7 +94,19 @@ export function MeLayoutPage() {
setMobileOpen(false) 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} /> <ListItemText primary={i.label} />
</ListItemButton> </ListItemButton>
))} ))}
@@ -1,13 +1,204 @@
import { useEffect, useMemo, useState } from 'react'
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 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 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() { 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 ( return (
<Box> <Box>
<Typography variant="h4" gutterBottom> <Typography variant="h4" gutterBottom>
Сообщения Сообщения
</Typography> </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> </Box>
) )
} }
@@ -1,17 +1,50 @@
import { useMemo, useState } from 'react' import { useEffect, useMemo, 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 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 Divider from '@mui/material/Divider'
import Rating from '@mui/material/Rating'
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'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import axios from 'axios'
import { Link as RouterLink, useParams } from 'react-router-dom' 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 { 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 = { type AddressSnapshot = {
deliveryType?: 'delivery' | 'pickup'
label?: string | null label?: string | null
recipientName?: string recipientName?: string
recipientPhone?: string recipientPhone?: string
@@ -23,6 +56,9 @@ export function OrderDetailPage() {
const { id } = useParams() const { id } = useParams()
const qc = useQueryClient() const qc = useQueryClient()
const [text, setText] = useState('') 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({ const orderQuery = useQuery({
queryKey: ['me', 'orders', id], queryKey: ['me', 'orders', id],
@@ -32,7 +68,20 @@ export function OrderDetailPage() {
const payMut = useMutation({ const payMut = useMutation({
mutationFn: () => payOrderStub(id!), 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({ const msgMut = useMutation({
@@ -40,11 +89,43 @@ export function OrderDetailPage() {
onSuccess: async () => { onSuccess: async () => {
setText('') setText('')
await qc.invalidateQueries({ queryKey: ['me', 'orders', id] }) await qc.invalidateQueries({ queryKey: ['me', 'orders', id] })
await qc.invalidateQueries({ queryKey: ['me', 'conversations'] })
}, },
}) })
const order = orderQuery.data?.item 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 => { const address = useMemo((): AddressSnapshot | null => {
if (!order?.addressSnapshotJson) return null if (!order?.addressSnapshotJson) return null
try { try {
@@ -63,7 +144,7 @@ export function OrderDetailPage() {
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} sx={{ mb: 2, alignItems: { sm: 'center' } }}> <Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} sx={{ mb: 2, alignItems: { sm: 'center' } }}>
<Box sx={{ flexGrow: 1 }}> <Box sx={{ flexGrow: 1 }}>
<Typography variant="h4">Заказ #{order.id.slice(-6)}</Typography> <Typography variant="h4">Заказ #{order.id.slice(-6)}</Typography>
<Typography color="text.secondary">Статус: {order.status}</Typography> <Typography color="text.secondary">Статус: {orderStatusLabelRu(order.status)}</Typography>
</Box> </Box>
<Button component={RouterLink} to="/me/orders" variant="outlined"> <Button component={RouterLink} to="/me/orders" variant="outlined">
К списку К списку
@@ -89,27 +170,49 @@ export function OrderDetailPage() {
))} ))}
</Stack> </Stack>
<Divider sx={{ my: 2 }} /> <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>
<Box sx={{ border: 1, borderColor: 'divider', borderRadius: 2, p: 2, bgcolor: 'background.paper' }}> <Box sx={{ border: 1, borderColor: 'divider', borderRadius: 2, p: 2, bgcolor: 'background.paper' }}>
<Typography variant="h6" gutterBottom> <Typography variant="h6" gutterBottom>
Доставка Получение
</Typography> </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> {address ? (
<Typography color="text.secondary" variant="body2"> <>
Получатель: {address.recipientName} · {address.recipientPhone} <Typography sx={{ fontWeight: 700 }}>{address.addressLine}</Typography>
</Typography> <Typography color="text.secondary" variant="body2">
{address.comment && ( Получатель: {address.recipientName} · {address.recipientPhone}
<Typography color="text.secondary" variant="body2"> </Typography>
Комментарий: {address.comment} {address.comment && (
</Typography> <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 && ( {order.comment && (
<Typography color="text.secondary" variant="body2" sx={{ mt: 1 }}> <Typography color="text.secondary" variant="body2" sx={{ mt: 1 }}>
@@ -122,14 +225,81 @@ export function OrderDetailPage() {
<Typography variant="h6" gutterBottom> <Typography variant="h6" gutterBottom>
Оплата Оплата
</Typography> </Typography>
<Typography color="text.secondary" variant="body2" sx={{ mb: 1 }}> {order.status === 'PENDING_PAYMENT' && (
Пока это заглушка. Позже подключим реальную оплату. <>
</Typography> <Typography color="text.secondary" variant="body2" sx={{ mb: 1 }}>
<Button variant="contained" onClick={() => payMut.mutate()} disabled={payMut.isPending}> Пока это заглушка. После нажатия заказ перейдёт в статус «Проверка оплаты».
Оплатить (заглушка) </Typography>
</Button> <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>
{(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' }}> <Box sx={{ border: 1, borderColor: 'divider', borderRadius: 2, p: 2, bgcolor: 'background.paper' }}>
<Typography variant="h6" gutterBottom> <Typography variant="h6" gutterBottom>
Чат по заказу Чат по заказу
@@ -175,6 +345,48 @@ export function OrderDetailPage() {
</Stack> </Stack>
</Box> </Box>
</Stack> </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> </Box>
) )
} }
@@ -7,6 +7,7 @@ import { useQuery } from '@tanstack/react-query'
import { Link as RouterLink } from 'react-router-dom' import { Link as RouterLink } from 'react-router-dom'
import { fetchMyOrders } from '@/entities/order/api/order-api' import { fetchMyOrders } from '@/entities/order/api/order-api'
import { formatPriceRub } from '@/shared/lib/format-price' import { formatPriceRub } from '@/shared/lib/format-price'
import { orderStatusLabelRu } from '@/shared/lib/order-status-labels'
export function OrdersPage() { export function OrdersPage() {
const ordersQuery = useQuery({ const ordersQuery = useQuery({
@@ -44,7 +45,7 @@ export function OrdersPage() {
<Box sx={{ flexGrow: 1 }}> <Box sx={{ flexGrow: 1 }}>
<Typography sx={{ fontWeight: 700 }}>Заказ #{o.id.slice(-6)}</Typography> <Typography sx={{ fontWeight: 700 }}>Заказ #{o.id.slice(-6)}</Typography>
<Typography color="text.secondary" variant="body2"> <Typography color="text.secondary" variant="body2">
Статус: {o.status} · {o.itemsCount} поз. Статус: {orderStatusLabelRu(o.status)} · {o.itemsCount} поз.
</Typography> </Typography>
</Box> </Box>
<Typography sx={{ fontWeight: 700 }}>{formatPriceRub(o.totalCents)}</Typography> <Typography sx={{ fontWeight: 700 }}>{formatPriceRub(o.totalCents)}</Typography>
@@ -1,11 +1,16 @@
import { useMemo, useState } from 'react' import { useMemo, useState } from 'react'
import CloseIcon from '@mui/icons-material/Close' import CloseIcon from '@mui/icons-material/Close'
import StarRoundedIcon from '@mui/icons-material/StarRounded'
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 Chip from '@mui/material/Chip' import Chip from '@mui/material/Chip'
import Dialog from '@mui/material/Dialog' import Dialog from '@mui/material/Dialog'
import Divider from '@mui/material/Divider'
import IconButton from '@mui/material/IconButton' 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 Skeleton from '@mui/material/Skeleton'
import Stack from '@mui/material/Stack'
import Typography from '@mui/material/Typography' import Typography from '@mui/material/Typography'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { useParams } from 'react-router-dom' import { useParams } from 'react-router-dom'
@@ -14,8 +19,10 @@ import { Swiper, SwiperSlide } from 'swiper/react'
import 'swiper/css' import 'swiper/css'
import 'swiper/css/navigation' import 'swiper/css/navigation'
import { fetchPublicProduct } from '@/entities/product/api/product-api' 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 { ToggleCartIcon } from '@/features/cart/toggle-cart-icon'
import { formatPriceRub } from '@/shared/lib/format-price' import { formatPriceRub } from '@/shared/lib/format-price'
import { reviewsCountRu } from '@/shared/lib/reviews-count-ru'
export function ProductPage() { export function ProductPage() {
const { id } = useParams() const { id } = useParams()
@@ -28,6 +35,12 @@ export function ProductPage() {
enabled: Boolean(id), 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 imageUrls = useMemo(() => {
const p = productQuery.data const p = productQuery.data
if (!p) return [] if (!p) return []
@@ -150,6 +163,73 @@ export function ProductPage() {
) : ( ) : (
<Typography color="text.secondary">Описание появится позже.</Typography> <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> </Box>
<Dialog fullScreen open={viewerOpen} onClose={() => setViewerOpen(false)}> <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 apiBaseURL = import.meta.env.VITE_API_URL ?? '/api'
export const STORE_NAME = 'Рукодельная лавка' 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 = [ export const ORDER_STATUSES = [
'DRAFT', 'DRAFT',
'PENDING_PAYMENT', 'PENDING_PAYMENT',
'PAYMENT_VERIFICATION',
'PAID', 'PAID',
'IN_PROGRESS', 'IN_PROGRESS',
'SHIPPED', 'SHIPPED',
'READY_FOR_PICKUP',
'DONE', 'DONE',
'CANCELLED', 'CANCELLED',
] as const ] as const
export type OrderStatus = (typeof ORDER_STATUSES)[number] export type OrderStatus = (typeof ORDER_STATUSES)[number]
export const ORDER_STATUS_TRANSITIONS: Record<OrderStatus, OrderStatus[]> = { /** Следующие статусы, доступные админу (смена через PATCH). */
DRAFT: ['PENDING_PAYMENT', 'CANCELLED'], export function getAdminNextOrderStatuses(status: string, deliveryType: 'delivery' | 'pickup'): OrderStatus[] {
PENDING_PAYMENT: ['PAID', 'CANCELLED'], switch (status) {
PAID: ['IN_PROGRESS', 'CANCELLED'], case 'DRAFT':
IN_PROGRESS: ['SHIPPED', 'CANCELLED'], return ['PENDING_PAYMENT', 'CANCELLED']
SHIPPED: ['DONE'], case 'PENDING_PAYMENT':
DONE: [], return ['CANCELLED']
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 { export function canTransitionOrderStatus(from: string, to: string): boolean {
if (from === to) return true if (from === to) return true
const f = from as OrderStatus return getAdminNextOrderStatuses(from, 'delivery').includes(to as OrderStatus)
const list = ORDER_STATUS_TRANSITIONS[f]
return Array.isArray(list) ? list.includes(to as OrderStatus) : false
} }
+13
View File
@@ -1,13 +1,26 @@
const KEY = 'craftshop_admin_token' const KEY = 'craftshop_admin_token'
const TOKEN_EVENT = 'craftshop_admin_token_change'
export function getAdminToken(): string | null { export function getAdminToken(): string | null {
return sessionStorage.getItem(KEY) 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 { export function setAdminToken(token: string): void {
sessionStorage.setItem(KEY, token) sessionStorage.setItem(KEY, token)
notifyTokenListeners()
} }
export function clearAdminToken(): void { export function clearAdminToken(): void {
sessionStorage.removeItem(KEY) 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 { interface ImportMetaEnv {
readonly VITE_API_URL?: string readonly VITE_API_URL?: string
readonly VITE_STORE_EMAIL?: string
readonly VITE_STORE_PHONE?: string
readonly VITE_STORE_SOCIAL_NOTE?: string
} }
interface ImportMeta { 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>
)
}
+16 -1
View File
@@ -1,4 +1,19 @@
DATABASE_URL="file:./dev.db" DATABASE_URL="file:./dev.db"
PORT=3333 PORT=3333
ADMIN_API_TOKEN=замените-на-секрет ADMIN_API_TOKEN=замените-на-секрет
# CORS_ORIGIN=http://localhost:5173 JWT_SECRET=замените-на-секрет-jwt
# Разрешённый Origin фронта (через запятую при нескольких)
# CORS_ORIGIN=http://127.0.0.1:5173
# Публичные URL для OAuth redirect (локально обычно так):
SERVER_PUBLIC_URL=http://127.0.0.1:3333
CLIENT_PUBLIC_URL=http://127.0.0.1:5173
# VK OAuth: в кабинете VK задать redirect URI = SERVER_PUBLIC_URL + /api/auth/oauth/vk/callback
VK_CLIENT_ID=
VK_CLIENT_SECRET=
# Yandex OAuth: redirect URI = SERVER_PUBLIC_URL + /api/auth/oauth/yandex/callback
YANDEX_CLIENT_ID=
YANDEX_CLIENT_SECRET=
@@ -0,0 +1,25 @@
-- 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',
"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", "id", "status", "totalCents", "updatedAt", "userId") SELECT "addressSnapshotJson", "comment", "createdAt", "currency", "id", "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;
@@ -0,0 +1,35 @@
-- CreateTable
CREATE TABLE "UserOrderMessageReadState" (
"id" TEXT NOT NULL PRIMARY KEY,
"lastReadAt" DATETIME NOT NULL DEFAULT '1970-01-01 00:00:00 +00:00',
"updatedAt" DATETIME NOT NULL,
"userId" TEXT NOT NULL,
"orderId" TEXT NOT NULL,
CONSTRAINT "UserOrderMessageReadState_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "UserOrderMessageReadState_orderId_fkey" FOREIGN KEY ("orderId") REFERENCES "Order" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "OAuthAccount" (
"id" TEXT NOT NULL PRIMARY KEY,
"provider" TEXT NOT NULL,
"providerUserId" TEXT NOT NULL,
"accessToken" TEXT,
"refreshToken" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
"userId" TEXT NOT NULL,
CONSTRAINT "OAuthAccount_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateIndex
CREATE INDEX "UserOrderMessageReadState_userId_idx" ON "UserOrderMessageReadState"("userId");
-- CreateIndex
CREATE UNIQUE INDEX "UserOrderMessageReadState_userId_orderId_key" ON "UserOrderMessageReadState"("userId", "orderId");
-- CreateIndex
CREATE INDEX "OAuthAccount_userId_idx" ON "OAuthAccount"("userId");
-- CreateIndex
CREATE UNIQUE INDEX "OAuthAccount_provider_providerUserId_key" ON "OAuthAccount"("provider", "providerUserId");
+40 -1
View File
@@ -69,6 +69,23 @@ model User {
cartItems CartItem[] cartItems CartItem[]
orders Order[] orders Order[]
reviews Review[] reviews Review[]
orderMessageReadStates UserOrderMessageReadState[]
oauthAccounts OAuthAccount[]
}
/// Прочитанность чата по заказу (для сообщений от админа после lastReadAt)
model UserOrderMessageReadState {
id String @id @default(cuid())
lastReadAt DateTime @default("1970-01-01T00:00:00.000Z")
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
userId String
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
orderId String
@@unique([userId, orderId])
@@index([userId])
} }
model CartItem { model CartItem {
@@ -90,9 +107,13 @@ model Order {
id String @id @default(cuid()) id String @id @default(cuid())
/// Статус заказа (валидация переходов на уровне API) /// Статус заказа (валидация переходов на уровне API)
status String @default("DRAFT") status String @default("DRAFT")
/// 'delivery' | 'pickup'
deliveryType String @default("delivery")
itemsSubtotalCents Int @default(0)
deliveryFeeCents Int @default(0)
totalCents Int @default(0) totalCents Int @default(0)
currency String @default("RUB") currency String @default("RUB")
addressSnapshotJson String addressSnapshotJson String?
comment String? comment String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@ -102,6 +123,7 @@ model Order {
items OrderItem[] items OrderItem[]
messages OrderMessage[] messages OrderMessage[]
messageReadStates UserOrderMessageReadState[]
@@index([userId, createdAt]) @@index([userId, createdAt])
@@index([status, updatedAt]) @@index([status, updatedAt])
@@ -175,6 +197,23 @@ model ShippingAddress {
@@index([userId, updatedAt]) @@index([userId, updatedAt])
} }
model OAuthAccount {
id String @id @default(cuid())
/// 'vk' | 'yandex'
provider String
providerUserId String
accessToken String?
refreshToken String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
userId String
@@unique([provider, providerUserId])
@@index([userId])
}
model AuthCode { model AuthCode {
id String @id @default(cuid()) id String @id @default(cuid())
email String email String
+2
View File
@@ -8,6 +8,7 @@ import path from 'node:path'
import { registerAuth } from './plugins/auth.js' import { registerAuth } from './plugins/auth.js'
import { registerApiRoutes } from './routes/api.js' import { registerApiRoutes } from './routes/api.js'
import { registerAuthRoutes } from './routes/auth.js' import { registerAuthRoutes } from './routes/auth.js'
import { registerOAuthSocialRoutes } from './routes/oauth-social.js'
const port = Number(process.env.PORT) || 3333 const port = Number(process.env.PORT) || 3333
const origin = (process.env.CORS_ORIGIN ?? '') const origin = (process.env.CORS_ORIGIN ?? '')
@@ -49,6 +50,7 @@ fastify.decorate('authenticate', async function authenticate(request, reply) {
registerAuth(fastify) registerAuth(fastify)
await registerAuthRoutes(fastify) await registerAuthRoutes(fastify)
await registerOAuthSocialRoutes(fastify)
await registerApiRoutes(fastify) await registerApiRoutes(fastify)
fastify.get('/health', async () => ({ ok: true })) fastify.get('/health', async () => ({ ok: true }))
+35 -12
View File
@@ -1,26 +1,49 @@
export const ORDER_STATUSES = [ export const ORDER_STATUSES = [
'DRAFT', 'DRAFT',
'PENDING_PAYMENT', 'PENDING_PAYMENT',
'PAYMENT_VERIFICATION',
'PAID', 'PAID',
'IN_PROGRESS', 'IN_PROGRESS',
'SHIPPED', 'SHIPPED',
'READY_FOR_PICKUP',
'DONE', 'DONE',
'CANCELLED', 'CANCELLED',
] ]
export const ORDER_STATUS_TRANSITIONS = { /**
DRAFT: new Set(['PENDING_PAYMENT', 'CANCELLED']), * Переходы, которые делает админ через PATCH /api/admin/orders/:id/status
PENDING_PAYMENT: new Set(['PAID', 'CANCELLED']), * (подтверждение получения пользователем — отдельный эндпоинт).
PAID: new Set(['IN_PROGRESS', 'CANCELLED']), */
IN_PROGRESS: new Set(['SHIPPED', 'CANCELLED']), export function canTransitionAdminOrderStatus(order, next) {
SHIPPED: new Set(['DONE']), const from = order.status
DONE: new Set([]), const dt = order.deliveryType
CANCELLED: new Set([]), if (from === next) return true
switch (from) {
case 'DRAFT':
return next === 'PENDING_PAYMENT' || next === 'CANCELLED'
case 'PENDING_PAYMENT':
return next === 'CANCELLED'
case 'PAYMENT_VERIFICATION':
return next === 'PAID' || next === 'CANCELLED'
case 'PAID':
return next === 'IN_PROGRESS' || next === 'CANCELLED'
case 'IN_PROGRESS':
if (next === 'CANCELLED') return true
if (dt === 'delivery') return next === 'SHIPPED'
if (dt === 'pickup') return next === 'READY_FOR_PICKUP'
return false
case 'SHIPPED':
case 'READY_FOR_PICKUP':
case 'DONE':
case 'CANCELLED':
return false
default:
return false
}
} }
/** @deprecated используйте canTransitionAdminOrderStatus */
export function canTransitionOrderStatus(from, to) { export function canTransitionOrderStatus(from, to) {
if (from === to) return true return canTransitionAdminOrderStatus({ status: from, deliveryType: 'delivery' }, to)
const allowed = ORDER_STATUS_TRANSITIONS[from]
return Boolean(allowed?.has(to))
} }
+13
View File
@@ -0,0 +1,13 @@
/** Публичное отображение автора отзыва (без «голого» email). */
export function publicReviewAuthorDisplay(user) {
if (!user || typeof user !== 'object') return 'Покупатель'
const name = typeof user.name === 'string' ? user.name.trim() : ''
if (name) return name
const email = typeof user.email === 'string' ? user.email.trim() : ''
const at = email.indexOf('@')
if (at <= 0) return 'Покупатель'
const local = email.slice(0, at)
const domain = email.slice(at + 1)
const masked = local.length <= 1 ? '*' : `${local.slice(0, 1)}***`
return `${masked}@${domain}`
}
+6 -2
View File
@@ -43,10 +43,14 @@ export function materialsFromDb(materials) {
} }
} }
export function mapProductForApi(p) { export function mapProductForApi(p, reviewsSummary = null) {
return { const base = {
...p, ...p,
materials: materialsFromDb(p.materials), materials: materialsFromDb(p.materials),
} }
if (reviewsSummary && typeof reviewsSummary === 'object') {
base.reviewsSummary = reviewsSummary
}
return base
} }
+22 -2
View File
@@ -1,13 +1,26 @@
import { prisma } from '../../lib/prisma.js' import { prisma } from '../../lib/prisma.js'
import { canTransitionOrderStatus } from '../../lib/order-status.js' import { canTransitionAdminOrderStatus } from '../../lib/order-status.js'
export async function registerAdminOrderRoutes(fastify) { export async function registerAdminOrderRoutes(fastify) {
fastify.get(
'/api/admin/orders/summary',
{ preHandler: [fastify.verifyAdmin] },
async () => {
const attentionCount = await prisma.order.count({
where: { status: { in: ['PENDING_PAYMENT', 'PAYMENT_VERIFICATION'] } },
})
return { attentionCount }
},
)
fastify.get( fastify.get(
'/api/admin/orders', '/api/admin/orders',
{ preHandler: [fastify.verifyAdmin] }, { preHandler: [fastify.verifyAdmin] },
async (request, reply) => { async (request, reply) => {
const status = typeof request.query?.status === 'string' ? request.query.status.trim() : '' const status = typeof request.query?.status === 'string' ? request.query.status.trim() : ''
const q = typeof request.query?.q === 'string' ? request.query.q.trim() : '' const q = typeof request.query?.q === 'string' ? request.query.q.trim() : ''
const deliveryTypeRaw = request.query?.deliveryType
const deliveryType = typeof deliveryTypeRaw === 'string' ? deliveryTypeRaw.trim() : ''
const pageRaw = request.query?.page const pageRaw = request.query?.page
const pageParsed = typeof pageRaw === 'string' ? Number(pageRaw) : Number(pageRaw) const pageParsed = typeof pageRaw === 'string' ? Number(pageRaw) : Number(pageRaw)
@@ -20,6 +33,12 @@ export async function registerAdminOrderRoutes(fastify) {
const where = {} const where = {}
if (status) where.status = status if (status) where.status = status
if (deliveryType) {
if (deliveryType !== 'delivery' && deliveryType !== 'pickup') {
return reply.code(400).send({ error: 'deliveryType должен быть delivery | pickup' })
}
where.deliveryType = deliveryType
}
if (q) { if (q) {
where.OR = [{ id: { contains: q } }, { user: { email: { contains: q } } }] where.OR = [{ id: { contains: q } }, { user: { email: { contains: q } } }]
} }
@@ -37,6 +56,7 @@ export async function registerAdminOrderRoutes(fastify) {
items: items.map((o) => ({ items: items.map((o) => ({
id: o.id, id: o.id,
status: o.status, status: o.status,
deliveryType: o.deliveryType,
totalCents: o.totalCents, totalCents: o.totalCents,
currency: o.currency, currency: o.currency,
createdAt: o.createdAt, createdAt: o.createdAt,
@@ -79,7 +99,7 @@ export async function registerAdminOrderRoutes(fastify) {
const existing = await prisma.order.findUnique({ where: { id } }) const existing = await prisma.order.findUnique({ where: { id } })
if (!existing) return reply.code(404).send({ error: 'Заказ не найден' }) if (!existing) return reply.code(404).send({ error: 'Заказ не найден' })
if (!canTransitionOrderStatus(existing.status, next)) { if (!canTransitionAdminOrderStatus(existing, next)) {
return reply.code(409).send({ error: `Нельзя сменить статус ${existing.status}${next}` }) return reply.code(409).send({ error: `Нельзя сменить статус ${existing.status}${next}` })
} }
+79 -4
View File
@@ -1,5 +1,63 @@
import { prisma } from '../../lib/prisma.js' import { prisma } from '../../lib/prisma.js'
const EMPTY_REVIEWS_SUMMARY = Object.freeze({
approvedReviewCount: 0,
avgRating: null,
latestApprovedText: null,
})
/** Сводка по одобренным отзывам для списка id товаров (для каталога и карточки товара). */
export async function approvedReviewSummariesForProducts(productIds) {
const map = new Map()
if (!productIds.length) return map
const uniqueIds = [...new Set(productIds)]
for (const id of uniqueIds) {
map.set(id, { ...EMPTY_REVIEWS_SUMMARY })
}
const grouped = await prisma.review.groupBy({
by: ['productId'],
where: { productId: { in: uniqueIds }, status: 'approved' },
_count: { _all: true },
_avg: { rating: true },
})
for (const g of grouped) {
const avg = g._avg.rating
const prev = map.get(g.productId)
if (!prev) continue
map.set(g.productId, {
...prev,
approvedReviewCount: g._count._all,
avgRating: avg != null ? Number(avg) : null,
})
}
const withReviews = [...map.entries()].filter(([, v]) => v.approvedReviewCount > 0).map(([k]) => k)
if (!withReviews.length) return map
const previewRows = await prisma.review.findMany({
where: { productId: { in: withReviews }, status: 'approved' },
orderBy: { createdAt: 'desc' },
select: { productId: true, text: true },
take: 450,
})
const hasPreviewFor = new Set()
for (const r of previewRows) {
if (hasPreviewFor.has(r.productId)) continue
const t = typeof r.text === 'string' ? r.text.trim() : ''
if (!t) continue
hasPreviewFor.add(r.productId)
const prev = map.get(r.productId)
if (!prev) continue
prev.latestApprovedText = t.length > 160 ? `${t.slice(0, 160)}` : t
if (hasPreviewFor.size === withReviews.length) break
}
return map
}
export async function registerPublicCatalogRoutes(fastify, { mapProductForApi } = {}) { export async function registerPublicCatalogRoutes(fastify, { mapProductForApi } = {}) {
fastify.get('/api/categories', async () => { fastify.get('/api/categories', async () => {
return prisma.category.findMany({ orderBy: { sort: 'asc' } }) return prisma.category.findMany({ orderBy: { sort: 'asc' } })
@@ -9,6 +67,8 @@ export async function registerPublicCatalogRoutes(fastify, { mapProductForApi }
const { categorySlug } = request.query const { categorySlug } = request.query
const qRaw = request.query?.q const qRaw = request.query?.q
const q = typeof qRaw === 'string' ? qRaw.trim() : '' const q = typeof qRaw === 'string' ? qRaw.trim() : ''
const availabilityRaw = request.query?.availability
const availability = typeof availabilityRaw === 'string' ? availabilityRaw.trim() : ''
const sortRaw = request.query?.sort const sortRaw = request.query?.sort
const sort = typeof sortRaw === 'string' ? sortRaw : '' const sort = typeof sortRaw === 'string' ? sortRaw : ''
@@ -29,13 +89,21 @@ export async function registerPublicCatalogRoutes(fastify, { mapProductForApi }
const priceMaxParsed = typeof priceMaxRaw === 'string' ? Number(priceMaxRaw) : Number(priceMaxRaw) const priceMaxParsed = typeof priceMaxRaw === 'string' ? Number(priceMaxRaw) : Number(priceMaxRaw)
const priceMax = Number.isFinite(priceMaxParsed) && priceMaxParsed >= 0 ? Math.floor(priceMaxParsed) : null const priceMax = Number.isFinite(priceMaxParsed) && priceMaxParsed >= 0 ? Math.floor(priceMaxParsed) : null
const where = { published: true, quantity: { gt: 0 } } const where = { published: true }
if (typeof categorySlug === 'string' && categorySlug.length > 0) { if (typeof categorySlug === 'string' && categorySlug.length > 0) {
where.category = { slug: categorySlug } where.category = { slug: categorySlug }
} }
if (q) { if (q) {
where.OR = [{ title: { contains: q } }, { shortDescription: { contains: q } }] where.OR = [{ title: { contains: q } }, { shortDescription: { contains: q } }]
} }
if (availability === 'in_stock') {
where.inStock = true
where.quantity = { gt: 0 }
} else if (availability === 'made_to_order') {
where.inStock = false
} else if (availability && availability !== 'all') {
return reply.code(400).send({ error: 'availability должен быть all | in_stock | made_to_order' })
}
const applyPriceFilter = !(priceMin !== null && priceMax !== null && priceMin === 0 && priceMax === 0) const applyPriceFilter = !(priceMin !== null && priceMax !== null && priceMin === 0 && priceMax === 0)
if (applyPriceFilter && (priceMin !== null || priceMax !== null)) { if (applyPriceFilter && (priceMin !== null || priceMax !== null)) {
@@ -64,20 +132,27 @@ export async function registerPublicCatalogRoutes(fastify, { mapProductForApi }
take: pageSize, take: pageSize,
}) })
return { items: items.map(mapProductForApi), total, page, pageSize } const summaries = await approvedReviewSummariesForProducts(items.map((it) => it.id))
return {
items: items.map((p) => mapProductForApi(p, summaries.get(p.id) ?? EMPTY_REVIEWS_SUMMARY)),
total,
page,
pageSize,
}
}) })
fastify.get('/api/products/:id', async (request, reply) => { fastify.get('/api/products/:id', async (request, reply) => {
const { id } = request.params const { id } = request.params
const product = await prisma.product.findFirst({ const product = await prisma.product.findFirst({
where: { id, published: true, quantity: { gt: 0 } }, where: { id, published: true },
include: { category: true, images: { orderBy: { sort: 'asc' } } }, include: { category: true, images: { orderBy: { sort: 'asc' } } },
}) })
if (!product) { if (!product) {
reply.code(404).send({ error: 'Товар не найден' }) reply.code(404).send({ error: 'Товар не найден' })
return return
} }
return mapProductForApi(product) const summaries = await approvedReviewSummariesForProducts([product.id])
return mapProductForApi(product, summaries.get(product.id) ?? EMPTY_REVIEWS_SUMMARY)
}) })
} }
+40 -2
View File
@@ -1,6 +1,36 @@
import { publicReviewAuthorDisplay } from '../../lib/review-display.js'
import { prisma } from '../../lib/prisma.js' import { prisma } from '../../lib/prisma.js'
export async function registerPublicReviewRoutes(fastify) { export async function registerPublicReviewRoutes(fastify) {
fastify.get('/api/reviews/latest', async (request, reply) => {
const limitRaw = request.query?.limit
const limitParsed = typeof limitRaw === 'string' ? Number(limitRaw) : Number(limitRaw)
const parsed = Number.isFinite(limitParsed) && limitParsed > 0 ? Math.floor(limitParsed) : 5
const take = Math.min(parsed, 5)
const rows = await prisma.review.findMany({
where: { status: 'approved', product: { published: true } },
include: {
user: { select: { email: true, name: true } },
product: { select: { id: true, title: true } },
},
orderBy: { createdAt: 'desc' },
take,
})
const items = rows.map((r) => ({
id: r.id,
rating: r.rating,
text: r.text,
createdAt: r.createdAt,
authorDisplay: publicReviewAuthorDisplay(r.user),
productId: r.productId,
productTitle: r.product?.title ?? '',
}))
return { items }
})
fastify.get('/api/products/:id/reviews', async (request, reply) => { fastify.get('/api/products/:id/reviews', async (request, reply) => {
const { id } = request.params const { id } = request.params
@@ -18,14 +48,22 @@ export async function registerPublicReviewRoutes(fastify) {
const where = { productId: id, status: 'approved' } const where = { productId: id, status: 'approved' }
const total = await prisma.review.count({ where }) const total = await prisma.review.count({ where })
const items = await prisma.review.findMany({ const rawItems = await prisma.review.findMany({
where, where,
include: { user: { select: { id: true, name: true, email: true } } }, include: { user: { select: { email: true, name: true } } },
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: 'desc' },
skip: (page - 1) * pageSize, skip: (page - 1) * pageSize,
take: pageSize, take: pageSize,
}) })
const items = rawItems.map((r) => ({
id: r.id,
rating: r.rating,
text: r.text,
createdAt: r.createdAt,
authorDisplay: publicReviewAuthorDisplay(r.user),
}))
return { items, total, page, pageSize } return { items, total, page, pageSize }
}) })
+180 -20
View File
@@ -454,14 +454,26 @@ export async function registerAuthRoutes(fastify) {
{ preHandler: [fastify.authenticate] }, { preHandler: [fastify.authenticate] },
async (request, reply) => { async (request, reply) => {
const userId = request.user.sub const userId = request.user.sub
const deliveryTypeRaw = request.body?.deliveryType
const deliveryType =
deliveryTypeRaw === undefined || deliveryTypeRaw === null || deliveryTypeRaw === ''
? 'delivery'
: String(deliveryTypeRaw).trim()
const addressId = String(request.body?.addressId || '').trim() const addressId = String(request.body?.addressId || '').trim()
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()
if (!addressId) return reply.code(400).send({ error: 'Выберите адрес доставки' }) if (deliveryType !== 'delivery' && deliveryType !== 'pickup') {
return reply.code(400).send({ error: 'deliveryType должен быть delivery | pickup' })
}
const address = await prisma.shippingAddress.findFirst({ where: { id: addressId, userId } }) let address = null
if (!address) return reply.code(404).send({ error: 'Адрес не найден' }) if (deliveryType === 'delivery') {
if (!addressId) return reply.code(400).send({ error: 'Выберите адрес доставки' })
address = await prisma.shippingAddress.findFirst({ where: { id: addressId, userId } })
if (!address) return reply.code(404).send({ error: 'Адрес не найден' })
}
const cartItems = await prisma.cartItem.findMany({ const cartItems = await prisma.cartItem.findMany({
where: { userId }, where: { userId },
@@ -483,17 +495,26 @@ export async function registerAuthRoutes(fastify) {
priceCentsSnapshot: ci.product.priceCents, priceCentsSnapshot: ci.product.priceCents,
})) }))
const totalCents = itemsPayload.reduce((sum, i) => sum + i.priceCentsSnapshot * i.qty, 0) const itemsSubtotalCents = itemsPayload.reduce((sum, i) => sum + i.priceCentsSnapshot * i.qty, 0)
const addressSnapshotJson = JSON.stringify({ const totalQty = itemsPayload.reduce((sum, i) => sum + i.qty, 0)
id: address.id, const deliveryFeeCents =
label: address.label, deliveryType === 'delivery' ? 50000 * Math.max(1, Math.ceil(totalQty / 2)) : 0
recipientName: address.recipientName, const totalCents = itemsSubtotalCents + deliveryFeeCents
recipientPhone: address.recipientPhone,
addressLine: address.addressLine, const addressSnapshotJson =
comment: address.comment, deliveryType === 'pickup'
lat: address.lat, ? JSON.stringify({ deliveryType: 'pickup' })
lng: address.lng, : JSON.stringify({
}) deliveryType: 'delivery',
id: address.id,
label: address.label,
recipientName: address.recipientName,
recipientPhone: address.recipientPhone,
addressLine: address.addressLine,
comment: address.comment,
lat: address.lat,
lng: address.lng,
})
let created let created
try { try {
@@ -509,16 +530,15 @@ export async function registerAuthRoutes(fastify) {
throw new Error(`Недостаточно товара: "${ci.product.title}"`) throw new Error(`Недостаточно товара: "${ci.product.title}"`)
} }
const p = await tx.product.findUnique({ where: { id: ci.productId }, select: { quantity: true } })
if (p && p.quantity === 0) {
await tx.product.update({ where: { id: ci.productId }, data: { published: false } })
}
} }
const order = await tx.order.create({ const order = await tx.order.create({
data: { data: {
userId, userId,
status: 'PENDING_PAYMENT', status: 'PENDING_PAYMENT',
deliveryType,
itemsSubtotalCents,
deliveryFeeCents,
totalCents, totalCents,
currency: 'RUB', currency: 'RUB',
addressSnapshotJson, addressSnapshotJson,
@@ -612,6 +632,88 @@ export async function registerAuthRoutes(fastify) {
}, },
) )
fastify.get(
'/api/me/messages/unread-count',
{ preHandler: [fastify.authenticate] },
async (request) => {
const userId = request.user.sub
const orders = await prisma.order.findMany({ where: { userId }, select: { id: true } })
if (orders.length === 0) return { count: 0 }
const readStates = await prisma.userOrderMessageReadState.findMany({ where: { userId } })
const lastReadByOrder = new Map(readStates.map((r) => [r.orderId, r.lastReadAt]))
let count = 0
for (const o of orders) {
const lastRead = lastReadByOrder.get(o.id) ?? new Date(0)
const n = await prisma.orderMessage.count({
where: { orderId: o.id, authorType: 'admin', createdAt: { gt: lastRead } },
})
count += n
}
return { count }
},
)
fastify.get(
'/api/me/conversations',
{ preHandler: [fastify.authenticate] },
async (request) => {
const userId = request.user.sub
const orders = await prisma.order.findMany({
where: { userId, messages: { some: {} } },
select: {
id: true,
status: true,
deliveryType: true,
messages: { orderBy: { createdAt: 'desc' }, take: 1, select: { text: true, createdAt: true } },
},
orderBy: { updatedAt: 'desc' },
})
const readStates = await prisma.userOrderMessageReadState.findMany({ where: { userId } })
const lastReadByOrder = new Map(readStates.map((r) => [r.orderId, r.lastReadAt]))
const items = []
for (const o of orders) {
const lastMsg = o.messages[0]
if (!lastMsg) continue
const lastRead = lastReadByOrder.get(o.id) ?? new Date(0)
const unreadCount = await prisma.orderMessage.count({
where: { orderId: o.id, authorType: 'admin', createdAt: { gt: lastRead } },
})
items.push({
orderId: o.id,
status: o.status,
deliveryType: o.deliveryType,
lastMessageAt: lastMsg.createdAt,
preview: lastMsg.text.length > 280 ? `${lastMsg.text.slice(0, 277)}` : lastMsg.text,
unreadCount,
})
}
return { items }
},
)
fastify.post(
'/api/me/orders/:id/messages/read',
{ preHandler: [fastify.authenticate] },
async (request, reply) => {
const userId = request.user.sub
const { id } = request.params
const order = await prisma.order.findFirst({ where: { id, userId } })
if (!order) return reply.code(404).send({ error: 'Заказ не найден' })
const now = new Date()
await prisma.userOrderMessageReadState.upsert({
where: { userId_orderId: { userId, orderId: id } },
create: { userId, orderId: id, lastReadAt: now },
update: { lastReadAt: now },
})
return { ok: true }
},
)
fastify.post( fastify.post(
'/api/me/orders/:id/pay', '/api/me/orders/:id/pay',
{ preHandler: [fastify.authenticate] }, { preHandler: [fastify.authenticate] },
@@ -620,11 +722,69 @@ 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: 'Заказ не найден' })
// Заглушка: пока ничего не оплачиваем, просто подтверждаем намерение оплатить 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' } })
nextStatus = 'PENDING_PAYMENT'
} else if (order.status === 'PENDING_PAYMENT') {
await prisma.order.update({ where: { id }, data: { status: 'PAYMENT_VERIFICATION' } })
nextStatus = 'PAYMENT_VERIFICATION'
} }
return { ok: true, status: order.status === 'DRAFT' ? 'PENDING_PAYMENT' : order.status } return { ok: true, status: nextStatus }
},
)
fastify.get(
'/api/me/orders/:id/review-eligibility',
{ preHandler: [fastify.authenticate] },
async (request, reply) => {
const userId = request.user.sub
const { id } = request.params
const order = await prisma.order.findFirst({ where: { id, userId }, include: { items: true } })
if (!order) return reply.code(404).send({ error: 'Заказ не найден' })
if (order.status !== 'DONE') {
return { canReview: false, items: [] }
}
const uniq = new Map()
for (const it of order.items) {
if (!uniq.has(it.productId)) {
uniq.set(it.productId, { productId: it.productId, title: it.titleSnapshot })
}
}
const productIds = [...uniq.keys()]
const existing = await prisma.review.findMany({
where: { userId, productId: { in: productIds } },
select: { productId: true },
})
const reviewed = new Set(existing.map((r) => r.productId))
return {
canReview: true,
items: [...uniq.values()].map((x) => ({
...x,
hasReview: reviewed.has(x.productId),
})),
}
},
)
fastify.post(
'/api/me/orders/:id/confirm-received',
{ preHandler: [fastify.authenticate] },
async (request, reply) => {
const userId = request.user.sub
const { id } = request.params
const order = await prisma.order.findFirst({ where: { id, userId } })
if (!order) return reply.code(404).send({ error: 'Заказ не найден' })
const okDelivery = order.deliveryType === 'delivery' && order.status === 'SHIPPED'
const okPickup = order.deliveryType === 'pickup' && order.status === 'READY_FOR_PICKUP'
if (!okDelivery && !okPickup) {
return reply.code(409).send({ error: 'Сейчас нельзя подтвердить получение заказа' })
}
await prisma.order.update({ where: { id }, data: { status: 'DONE' } })
return { ok: true, status: 'DONE' }
}, },
) )
} }
+244
View File
@@ -0,0 +1,244 @@
import { normalizeEmail } from '../lib/auth.js'
import { prisma } from '../lib/prisma.js'
function clientRedirect(fastify, reply, token) {
const base = process.env.CLIENT_PUBLIC_URL || 'http://127.0.0.1:5173'
const url = `${base.replace(/\/$/, '')}/auth/callback?token=${encodeURIComponent(token)}`
return reply.redirect(url)
}
function oauthErrorRedirect(reply, msg) {
const base = process.env.CLIENT_PUBLIC_URL || 'http://127.0.0.1:5173'
const url = `${base.replace(/\/$/, '')}/auth?oauthError=${encodeURIComponent(msg)}`
return reply.redirect(url)
}
async function issueUserJwt(fastify, userId, email) {
return fastify.jwt.sign({ sub: userId, email })
}
async function findOrCreateUserFromOAuth({ provider, providerUserId, accessToken, suggestedEmail }) {
const existingLink = await prisma.oauthAccount.findUnique({
where: { provider_providerUserId: { provider, providerUserId } },
include: { user: true },
})
if (existingLink?.user) {
if (accessToken !== undefined) {
await prisma.oauthAccount.update({
where: { provider_providerUserId: { provider, providerUserId } },
data: { accessToken },
})
}
return existingLink.user
}
const trimmed = typeof suggestedEmail === 'string' ? suggestedEmail.trim() : ''
const norm = trimmed ? normalizeEmail(trimmed) : null
let user = norm ? await prisma.user.findUnique({ where: { email: norm } }) : null
if (user) {
await prisma.oauthAccount.create({
data: { provider, providerUserId: String(providerUserId), userId: user.id, accessToken },
})
return user
}
let email = norm || `${provider}_${providerUserId}@oauth.craftshop.local`
let n = 0
while (await prisma.user.findUnique({ where: { email } })) {
n += 1
email = `${provider}_${providerUserId}_${n}@oauth.craftshop.local`
}
user = await prisma.user.create({ data: { email } })
await prisma.oauthAccount.create({
data: { provider, providerUserId: String(providerUserId), userId: user.id, accessToken },
})
return user
}
export async function registerOAuthSocialRoutes(fastify) {
const serverPublic = process.env.SERVER_PUBLIC_URL || 'http://127.0.0.1:3333'
/** --- VK --- */
fastify.get('/api/auth/oauth/vk', async (_request, reply) => {
const clientId = process.env.VK_CLIENT_ID
const clientSecret = process.env.VK_CLIENT_SECRET
if (!clientId || !clientSecret) return reply.code(503).send({ error: 'VK OAuth не настроен (нет VK_* в env)' })
const redirectUri = `${serverPublic}/api/auth/oauth/vk/callback`
const state = fastify.jwt.sign({ oauth: 'vk' }, { expiresIn: '15m' })
const url = new URL('https://oauth.vk.com/authorize')
url.searchParams.set('client_id', clientId)
url.searchParams.set('display', 'page')
url.searchParams.set('redirect_uri', redirectUri)
url.searchParams.set('scope', 'email')
url.searchParams.set('response_type', 'code')
url.searchParams.set('v', '5.199')
url.searchParams.set('state', state)
return reply.redirect(url.toString())
})
fastify.get('/api/auth/oauth/vk/callback', async (request, reply) => {
const query = request.query ?? {}
if (query.error || query.error_description) {
return oauthErrorRedirect(reply, String(query.error_description || query.error || 'ошибка VK'))
}
try {
const state = typeof query.state === 'string' ? query.state : ''
fastify.jwt.verify(state || '')
} catch {
return oauthErrorRedirect(reply, 'Недействительный state OAuth')
}
const code = typeof query.code === 'string' ? query.code.trim() : ''
if (!code) return oauthErrorRedirect(reply, 'Не получен код от VK')
const clientId = process.env.VK_CLIENT_ID
const clientSecret = process.env.VK_CLIENT_SECRET
const redirectUri = `${serverPublic}/api/auth/oauth/vk/callback`
const tokenUrl = new URL('https://oauth.vk.com/access_token')
tokenUrl.searchParams.set('client_id', clientId)
tokenUrl.searchParams.set('client_secret', clientSecret)
tokenUrl.searchParams.set('redirect_uri', redirectUri)
tokenUrl.searchParams.set('code', code)
const tokenRes = await fetch(tokenUrl.toString())
const tokenBody = await tokenRes.json()
if (tokenBody?.error_description || tokenBody?.error || !tokenRes.ok) {
return oauthErrorRedirect(reply, tokenBody?.error_description || tokenBody?.error || 'Не удалось обменять код VK')
}
const vkUserId = tokenBody?.user_id
const accessTokenVk = tokenBody?.access_token
let emailSuggestion = typeof tokenBody?.email === 'string' ? tokenBody.email : null
let firstName = null
let lastName = null
try {
if (accessTokenVk && vkUserId) {
const u = new URL('https://api.vk.com/method/users.get')
u.searchParams.set('access_token', accessTokenVk)
u.searchParams.set('users_ids', String(vkUserId))
u.searchParams.set('fields', 'photo_50')
u.searchParams.set('v', '5.199')
const profRes = await fetch(u.toString())
const prof = await profRes.json()
const u0 = prof?.response?.[0]
if (u0) {
firstName = u0.first_name ?? null
lastName = u0.last_name ?? null
}
}
} catch {
// ignore profile extras
}
const user = await findOrCreateUserFromOAuth({
provider: 'vk',
providerUserId: String(vkUserId),
accessToken: accessTokenVk ?? null,
suggestedEmail: emailSuggestion,
})
if (firstName || lastName) {
const name = [firstName, lastName].filter(Boolean).join(' ').trim()
if (name && !user.name) {
await prisma.user.update({ where: { id: user.id }, data: { name } })
}
}
const token = await issueUserJwt(fastify, user.id, user.email)
return clientRedirect(fastify, reply, token)
})
/** --- Yandex --- */
fastify.get('/api/auth/oauth/yandex', async (_request, reply) => {
const clientId = process.env.YANDEX_CLIENT_ID
if (!clientId) return reply.code(503).send({ error: 'Yandex OAuth не настроен (нет YANDEX_* в env)' })
const redirectUri = `${serverPublic}/api/auth/oauth/yandex/callback`
const state = fastify.jwt.sign({ oauth: 'yandex' }, { expiresIn: '15m' })
const url = new URL('https://oauth.yandex.ru/authorize')
url.searchParams.set('response_type', 'code')
url.searchParams.set('client_id', clientId)
url.searchParams.set('redirect_uri', redirectUri)
url.searchParams.set('scope', 'login:email login:info')
url.searchParams.set('state', state)
return reply.redirect(url.toString())
})
fastify.get('/api/auth/oauth/yandex/callback', async (request, reply) => {
const query = request.query ?? {}
if (query.error) return oauthErrorRedirect(reply, String(query.error))
try {
const state = typeof query.state === 'string' ? query.state : ''
fastify.jwt.verify(state || '')
} catch {
return oauthErrorRedirect(reply, 'Недействительный state OAuth')
}
const code = typeof query.code === 'string' ? query.code.trim() : ''
if (!code) return oauthErrorRedirect(reply, 'Не получен код от Яндекс')
const clientId = process.env.YANDEX_CLIENT_ID
const clientSecret = process.env.YANDEX_CLIENT_SECRET
const redirectUri = `${serverPublic}/api/auth/oauth/yandex/callback`
const body = new URLSearchParams()
body.set('grant_type', 'authorization_code')
body.set('code', code)
body.set('client_id', clientId)
body.set('client_secret', clientSecret)
if (redirectUri) body.set('redirect_uri', redirectUri)
const tokenRes = await fetch('https://oauth.yandex.ru/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: body.toString(),
})
const tokenBody = await tokenRes.json()
if (!tokenRes.ok || !tokenBody.access_token) {
return oauthErrorRedirect(
reply,
tokenBody.error_description || tokenBody.error || 'Не удалось обменять код Yandex',
)
}
const yaToken = tokenBody.access_token
const infoRes = await fetch('https://login.yandex.ru/info', {
headers: { Authorization: `OAuth ${yaToken}` },
})
const info = await infoRes.json()
const yaUserId = String(info?.id || '')
if (!yaUserId) return oauthErrorRedirect(reply, 'Не удалось получить профиль Yandex')
const emailGuess =
(Array.isArray(info?.emails) && info.emails[0]) ||
info?.default_email ||
(info?.login ? `${info.login}@yandex.ru` : null)
const user = await findOrCreateUserFromOAuth({
provider: 'yandex',
providerUserId: yaUserId,
accessToken: yaToken,
suggestedEmail: emailGuess || null,
})
const dn = `${info.first_name ?? ''} ${info.last_name ?? ''}`.trim()
if (dn && !user.name) {
await prisma.user.update({ where: { id: user.id }, data: { name: dn } })
}
const token = await issueUserJwt(fastify, user.id, user.email)
return clientRedirect(fastify, reply, token)
})
}