base commit
This commit is contained in:
@@ -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,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 />} />
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 +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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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()}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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)}>
|
||||||
|
|||||||
@@ -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 ?? 'Соцсети: укажите ссылки при публикации'
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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}`
|
||||||
|
}
|
||||||
Vendored
+3
@@ -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
@@ -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;
|
||||||
+35
@@ -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");
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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 }))
|
||||||
|
|||||||
@@ -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))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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}`
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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}` })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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' }
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user