base commit

This commit is contained in:
@kirill.komarov
2026-04-30 22:34:55 +05:00
parent 123d86091d
commit 9139a24093
46 changed files with 2023 additions and 153 deletions
+18
View File
@@ -81,6 +81,24 @@ npm run dev
Для боевого размещения фронта и API на разных доменах задайте `VITE_API_URL` (например `https://api.example.com/api`) и **CORS_ORIGIN** на сервере.
### 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 (кратко)
Публичные:
+2 -1
View File
@@ -2,7 +2,7 @@ import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'
import { MainLayout } from '@/app/layout/MainLayout'
import { AppProviders } from '@/app/providers/AppProviders'
import { AdminLayoutPage } from '@/pages/admin-layout'
import { AuthPage } from '@/pages/auth'
import { AuthCallbackPage, AuthPage } from '@/pages/auth'
import { CartPage } from '@/pages/cart'
import { CheckoutPage } from '@/pages/checkout'
import { HomePage } from '@/pages/home'
@@ -18,6 +18,7 @@ export function App() {
<Route path="/" element={<HomePage />} />
<Route path="/admin/*" element={<AdminLayoutPage />} />
<Route path="/auth" element={<AuthPage />} />
<Route path="/auth/callback" element={<AuthCallbackPage />} />
<Route path="/cart" element={<CartPage />} />
<Route path="/checkout" element={<CheckoutPage />} />
<Route path="/me/*" element={<MeLayoutPage />} />
+8 -8
View File
@@ -62,11 +62,11 @@ function ThemeControlsDesktop(props: {
'& .MuiInputLabel-root.MuiInputLabel-shrink': { color: 'rgba(255,255,255,0.92)' },
}}
>
<InputLabel id="scheme-label">Тема</InputLabel>
<InputLabel id="scheme-label">Схема</InputLabel>
<Select
labelId="scheme-label"
value={scheme}
label="Тема"
label="Схема"
onChange={onSchemeChange}
sx={{
color: 'inherit',
@@ -93,11 +93,11 @@ function ThemeControlsDesktop(props: {
'& .MuiInputLabel-root.MuiInputLabel-shrink': { color: 'rgba(255,255,255,0.92)' },
}}
>
<InputLabel id="mode-label">Режим</InputLabel>
<InputLabel id="mode-label">Тема</InputLabel>
<Select
labelId="mode-label"
value={mode}
label="Режим"
label="Тема"
onChange={onModeChange}
sx={{
color: 'inherit',
@@ -139,8 +139,8 @@ function ThemeControlsMobile(props: {
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<FormControl size="small" fullWidth>
<InputLabel id="scheme-label-mobile">Тема</InputLabel>
<Select labelId="scheme-label-mobile" value={scheme} label="Тема" onChange={onSchemeChange}>
<InputLabel id="scheme-label-mobile">Схема</InputLabel>
<Select labelId="scheme-label-mobile" value={scheme} label="Схема" onChange={onSchemeChange}>
<MenuItem value="craft">Крафт</MenuItem>
<MenuItem value="forest">Лес</MenuItem>
<MenuItem value="ocean">Океан</MenuItem>
@@ -149,8 +149,8 @@ function ThemeControlsMobile(props: {
</FormControl>
<FormControl size="small" fullWidth>
<InputLabel id="mode-label-mobile">Режим</InputLabel>
<Select labelId="mode-label-mobile" value={mode} label="Режим" onChange={onModeChange}>
<InputLabel id="mode-label-mobile">Тема</InputLabel>
<Select labelId="mode-label-mobile" value={mode} label="Тема" onChange={onModeChange}>
<MenuItem value="system">Авто (система)</MenuItem>
<MenuItem value="light">Светлая</MenuItem>
<MenuItem value="dark">Тёмная</MenuItem>
+71 -4
View File
@@ -1,10 +1,18 @@
import { type PropsWithChildren } from 'react'
import Box from '@mui/material/Box'
import Container from '@mui/material/Container'
import Divider from '@mui/material/Divider'
import Grid from '@mui/material/Grid'
import Link from '@mui/material/Link'
import Stack from '@mui/material/Stack'
import Typography from '@mui/material/Typography'
import { Link as RouterLink } from 'react-router-dom'
import { AppHeader } from '@/app/layout/AppHeader'
import { STORE_EMAIL, STORE_NAME, STORE_PHONE, STORE_SOCIAL_NOTE } from '@/shared/config'
export function MainLayout({ children }: PropsWithChildren) {
const year = new Date().getFullYear()
return (
<Box sx={{ display: 'flex', flexDirection: 'column', minHeight: '100vh' }}>
<AppHeader />
@@ -12,17 +20,76 @@ export function MainLayout({ children }: PropsWithChildren) {
<Box component="main" sx={{ flex: 1, py: 3 }}>
<Container maxWidth="lg">{children}</Container>
</Box>
<Box
component="footer"
sx={{
py: 2,
textAlign: 'center',
mt: 'auto',
borderTop: 1,
borderColor: 'divider',
color: 'text.secondary',
bgcolor: 'background.default',
py: { xs: 3, md: 4 },
}}
>
<Typography variant="body2">Изделия ручной работы · доставка по договорённости</Typography>
<Container maxWidth="lg">
<Grid container spacing={3}>
<Grid size={{ xs: 12, sm: 4 }}>
<Typography variant="subtitle1" fontWeight={700} gutterBottom>
Магазин
</Typography>
<Stack spacing={1}>
<Link component={RouterLink} to="/" color="inherit" underline="hover" variant="body2">
Каталог
</Link>
<Typography variant="body2" color="text.secondary">
Изделия ручной работы: вещь с характером и вниманием к деталям.
</Typography>
<Typography variant="body2" color="text.secondary">
Как заказать: добавьте позиции в корзину и оформите доставку или самовывоз на чек-ауте.
</Typography>
</Stack>
</Grid>
<Grid size={{ xs: 12, sm: 4 }}>
<Typography variant="subtitle1" fontWeight={700} gutterBottom>
Покупателям
</Typography>
<Stack spacing={1}>
<Link component={RouterLink} to="/me" color="inherit" underline="hover" variant="body2">
Личный кабинет
</Link>
<Typography variant="body2" color="text.secondary">
Доставка и самовывоз: уточняются при оформлении заказа; по вопросам контакты ниже.
</Typography>
</Stack>
</Grid>
<Grid size={{ xs: 12, sm: 4 }}>
<Typography variant="subtitle1" fontWeight={700} gutterBottom>
Контакты
</Typography>
<Stack spacing={0.75}>
<Typography variant="body2">
Email:{' '}
<Link href={`mailto:${STORE_EMAIL}`} underline="hover">
{STORE_EMAIL}
</Link>
</Typography>
<Typography variant="body2">
Телефон:{' '}
<Link href={`tel:${STORE_PHONE.replace(/\s/g, '')}`} underline="hover">
{STORE_PHONE}
</Link>
</Typography>
<Typography variant="caption" color="text.secondary">
{STORE_SOCIAL_NOTE}
</Typography>
</Stack>
</Grid>
</Grid>
<Divider sx={{ my: 2 }} />
<Typography variant="caption" color="text.secondary" display="block" textAlign={{ xs: 'left', sm: 'center' }}>
© {year} {STORE_NAME}. Сделано для демонстрации возможностей витрины.
</Typography>
</Container>
</Box>
</Box>
)
@@ -3,6 +3,7 @@ import { apiClient } from '@/shared/api/client'
export type AdminOrderListItem = {
id: string
status: string
deliveryType: 'delivery' | 'pickup'
totalCents: number
currency: string
createdAt: string
@@ -22,9 +23,12 @@ export type AdminOrderDetailResponse = {
item: {
id: string
status: string
deliveryType: 'delivery' | 'pickup'
itemsSubtotalCents: number
deliveryFeeCents: number
totalCents: number
currency: string
addressSnapshotJson: string
addressSnapshotJson: string | null
comment: string | null
createdAt: string
updatedAt: string
@@ -45,9 +49,16 @@ export type AdminOrderDetailResponse = {
}
}
export async function fetchAdminOrdersSummary(token: string): Promise<{ attentionCount: number }> {
const { data } = await apiClient.get<{ attentionCount: number }>('admin/orders/summary', {
headers: { Authorization: `Bearer ${token}` },
})
return data
}
export async function fetchAdminOrders(
token: string,
params?: { status?: string; q?: string; page?: number; pageSize?: number },
params?: { status?: string; deliveryType?: 'delivery' | 'pickup'; q?: string; page?: number; pageSize?: number },
): Promise<AdminOrdersListResponse> {
const { data } = await apiClient.get<AdminOrdersListResponse>('admin/orders', {
params,
+29 -4
View File
@@ -16,9 +16,12 @@ export type OrderDetailResponse = {
item: {
id: string
status: string
deliveryType: 'delivery' | 'pickup'
itemsSubtotalCents: number
deliveryFeeCents: number
totalCents: number
currency: string
addressSnapshotJson: string
addressSnapshotJson: string | null
comment: string | null
createdAt: string
updatedAt: string
@@ -38,7 +41,11 @@ export type OrderDetailResponse = {
}
}
export async function createOrder(body: { addressId: string; comment?: string | null }): Promise<{ orderId: string }> {
export async function createOrder(body: {
deliveryType: 'delivery' | 'pickup'
addressId?: string | null
comment?: string | null
}): Promise<{ orderId: string }> {
const { data } = await apiClient.post<{ orderId: string }>('me/orders', body)
return data
}
@@ -57,6 +64,24 @@ export async function postOrderMessage(id: string, text: string): Promise<void>
await apiClient.post(`me/orders/${id}/messages`, { text })
}
export async function payOrderStub(id: string): Promise<void> {
await apiClient.post(`me/orders/${id}/pay`)
export async function payOrderStub(id: string): Promise<{ ok: boolean; status: string }> {
const { data } = await apiClient.post<{ ok: boolean; status: string }>(`me/orders/${id}/pay`)
return data
}
export async function confirmOrderReceived(id: string): Promise<{ ok: boolean; status: string }> {
const { data } = await apiClient.post<{ ok: boolean; status: string }>(`me/orders/${id}/confirm-received`)
return data
}
export type ReviewEligibilityItem = { productId: string; title: string; hasReview: boolean }
export async function fetchOrderReviewEligibility(orderId: string): Promise<{
canReview: boolean
items: ReviewEligibilityItem[]
}> {
const { data } = await apiClient.get<{ canReview: boolean; items: ReviewEligibilityItem[] }>(
`me/orders/${orderId}/review-eligibility`,
)
return data
}
@@ -12,6 +12,7 @@ export async function fetchPublicProducts(params?: {
categorySlug?: string
q?: string
sort?: 'price_asc' | 'price_desc' | ''
availability?: 'all' | 'in_stock' | 'made_to_order'
page?: number
pageSize?: number
priceMinCents?: number
@@ -22,6 +23,7 @@ export async function fetchPublicProducts(params?: {
categorySlug: params?.categorySlug || undefined,
q: params?.q || undefined,
sort: params?.sort || undefined,
availability: params?.availability || undefined,
page: params?.page || undefined,
pageSize: params?.pageSize || undefined,
priceMin: params?.priceMinCents ?? undefined,
@@ -0,0 +1,54 @@
import { apiClient } from '@/shared/api/client'
export async function postProductReview(
productId: string,
body: { rating: number; text?: string | null },
): Promise<void> {
await apiClient.post(`products/${productId}/reviews`, body)
}
export type PublicReviewFeedItem = {
id: string
rating: number
text: string | null
createdAt: string
authorDisplay: string
productId: string
productTitle: string
}
export type PublicReviewsLatestResponse = {
items: PublicReviewFeedItem[]
}
export async function fetchLatestApprovedReviews(limit = 5): Promise<PublicReviewsLatestResponse> {
const { data } = await apiClient.get<PublicReviewsLatestResponse>('reviews/latest', {
params: { limit },
})
return data
}
export type PublicProductReviewItem = {
id: string
rating: number
text: string | null
createdAt: string
authorDisplay: string
}
export type PublicProductReviewsResponse = {
items: PublicProductReviewItem[]
total: number
page: number
pageSize: number
}
export async function fetchPublicProductReviews(
productId: string,
params?: { page?: number; pageSize?: number },
): Promise<PublicProductReviewsResponse> {
const { data } = await apiClient.get<PublicProductReviewsResponse>(`products/${productId}/reviews`, {
params: { page: params?.page, pageSize: params?.pageSize },
})
return data
}
@@ -5,6 +5,12 @@ export type Category = {
sort: number
}
export type ProductReviewsSummary = {
approvedReviewCount: number
avgRating: number | null
latestApprovedText: string | null
}
export type Product = {
id: string
title: string
@@ -24,4 +30,6 @@ export type Product = {
updatedAt: string
category?: Category
images?: { id: string; url: string; sort: number }[]
/** Для опубликованных товаров с публичного API. */
reviewsSummary?: ProductReviewsSummary | null
}
@@ -6,6 +6,7 @@ import CardContent from '@mui/material/CardContent'
import CardMedia from '@mui/material/CardMedia'
import Chip from '@mui/material/Chip'
import Link from '@mui/material/Link'
import Rating from '@mui/material/Rating'
import Stack from '@mui/material/Stack'
import Typography from '@mui/material/Typography'
import { Link as RouterLink } from 'react-router-dom'
@@ -13,6 +14,7 @@ import { Swiper, SwiperSlide } from 'swiper/react'
import 'swiper/css'
import type { Product } from '@/entities/product/model/types'
import { formatPriceRub } from '@/shared/lib/format-price'
import { reviewsCountRu } from '@/shared/lib/reviews-count-ru'
import type { Swiper as SwiperType } from 'swiper/types'
type Props = { product: Product; mediaHeight?: number; actions?: ReactNode }
@@ -117,6 +119,7 @@ export function ProductCard({ product, mediaHeight = 200, actions }: Props) {
<CardContent sx={{ flexGrow: 1 }}>
<Stack spacing={1}>
{product.category && <Chip label={product.category.name} size="small" />}
{product.inStock && product.quantity === 0 && <Chip label="Нет в наличии" size="small" color="default" />}
{!product.inStock && (
<Chip
label={`Под заказ · ${product.leadTimeDays ?? '—'} дн.`}
@@ -157,6 +160,38 @@ export function ProductCard({ product, mediaHeight = 200, actions }: Props) {
<Typography variant="h6" color="primary">
{formatPriceRub(product.priceCents)}
</Typography>
{product.reviewsSummary && product.reviewsSummary.approvedReviewCount > 0 && (
<Stack spacing={0.5}>
<Stack direction="row" spacing={0.75} sx={{ alignItems: 'center' }}>
<Rating
size="small"
value={product.reviewsSummary.avgRating ?? 0}
precision={0.25}
readOnly
sx={{ color: 'warning.main', fontSize: 18 }}
/>
<Typography variant="caption" color="text.secondary">
{reviewsCountRu(product.reviewsSummary.approvedReviewCount)}
</Typography>
</Stack>
{product.reviewsSummary.latestApprovedText ? (
<Typography
variant="caption"
color="text.secondary"
sx={{
fontStyle: 'italic',
overflow: 'hidden',
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
}}
title={product.reviewsSummary.latestApprovedText}
>
«{product.reviewsSummary.latestApprovedText}»
</Typography>
) : null}
</Stack>
)}
{actions}
</Stack>
</CardContent>
@@ -0,0 +1,24 @@
import { apiClient } from '@/shared/api/client'
export async function fetchUnreadMessageCount(): Promise<{ count: number }> {
const { data } = await apiClient.get<{ count: number }>('me/messages/unread-count')
return data
}
export async function markOrderMessagesRead(orderId: string): Promise<void> {
await apiClient.post(`me/orders/${orderId}/messages/read`)
}
export type ConversationSummary = {
orderId: string
status: string
deliveryType: 'delivery' | 'pickup'
lastMessageAt: string
preview: string
unreadCount: number
}
export async function fetchMyConversations(): Promise<{ items: ConversationSummary[] }> {
const { data } = await apiClient.get<{ items: ConversationSummary[] }>('me/conversations')
return data
}
@@ -9,8 +9,12 @@ import { useNavigate } from 'react-router-dom'
import { addToCart, fetchMyCart, removeCartItem } from '@/entities/cart/api/cart-api'
import { $user } from '@/shared/model/auth'
export function ToggleCartIcon(props: { productId: string; size?: 'small' | 'medium' }) {
const { productId, size = 'small' } = props
export function ToggleCartIcon(props: {
productId: string
size?: 'small' | 'medium'
disabledReason?: string | null
}) {
const { productId, size = 'small', disabledReason = null } = props
const user = useUnit($user)
const qc = useQueryClient()
const navigate = useNavigate()
@@ -34,12 +38,13 @@ export function ToggleCartIcon(props: { productId: string; size?: 'small' | 'med
onSuccess: () => void qc.invalidateQueries({ queryKey: ['me', 'cart'] }),
})
const disabled = !user
const disabled = !user || Boolean(disabledReason)
const busy = addMut.isPending || removeMut.isPending
const onClick = (e: React.MouseEvent) => {
e.preventDefault()
e.stopPropagation()
if (disabledReason) return
if (!user) {
navigate('/auth')
return
@@ -48,7 +53,13 @@ export function ToggleCartIcon(props: { productId: string; size?: 'small' | 'med
else addMut.mutate()
}
const tooltip = !user ? 'Авторизуйтесь для совершения покупок' : inCart ? 'Убрать из корзины' : 'В корзину'
const tooltip = disabledReason
? disabledReason
: !user
? 'Авторизуйтесь для совершения покупок'
: inCart
? 'Убрать из корзины'
: 'В корзину'
return (
<Tooltip title={tooltip}>
@@ -1,10 +1,11 @@
import type { ReactNode } from 'react'
import { useMemo, useState } from 'react'
import { useMemo, useState, useSyncExternalStore } from 'react'
import AssignmentOutlinedIcon from '@mui/icons-material/AssignmentOutlined'
import MenuOutlinedIcon from '@mui/icons-material/MenuOutlined'
import PeopleOutlinedIcon from '@mui/icons-material/PeopleOutlined'
import RateReviewOutlinedIcon from '@mui/icons-material/RateReviewOutlined'
import StorefrontOutlinedIcon from '@mui/icons-material/StorefrontOutlined'
import Badge from '@mui/material/Badge'
import Box from '@mui/material/Box'
import Divider from '@mui/material/Divider'
import Drawer from '@mui/material/Drawer'
@@ -17,11 +18,14 @@ import Stack from '@mui/material/Stack'
import { useTheme } from '@mui/material/styles'
import Typography from '@mui/material/Typography'
import useMediaQuery from '@mui/material/useMediaQuery'
import { useQuery } from '@tanstack/react-query'
import { Navigate, Route, Routes, useLocation, useNavigate } from 'react-router-dom'
import { fetchAdminOrdersSummary } from '@/entities/order/api/admin-order-api'
import { AdminPage } from '@/pages/admin'
import { AdminOrdersPage } from '@/pages/admin-orders'
import { AdminReviewsPage } from '@/pages/admin-reviews'
import { AdminUsersPage } from '@/pages/admin-users'
import { getAdminToken, subscribeAdminTokenChange } from '@/shared/lib/admin-token'
type NavItem = {
to: string
@@ -35,6 +39,17 @@ export function AdminLayoutPage() {
const theme = useTheme()
const isMobile = useMediaQuery(theme.breakpoints.down('md'))
const [mobileOpen, setMobileOpen] = useState(false)
const adminToken = useSyncExternalStore(subscribeAdminTokenChange, getAdminToken, () => null)
const ordersSummaryQuery = useQuery({
queryKey: ['admin', 'orders', 'summary', adminToken],
queryFn: () => fetchAdminOrdersSummary(adminToken!),
enabled: Boolean(adminToken),
refetchInterval: 45_000,
refetchOnWindowFocus: true,
})
const newOrdersAttention = ordersSummaryQuery.data?.attentionCount ?? 0
const navItems: NavItem[] = useMemo(
() => [
@@ -72,7 +87,15 @@ export function AdminLayoutPage() {
setMobileOpen(false)
}}
>
<ListItemIcon>{i.icon}</ListItemIcon>
<ListItemIcon>
{i.to === '/admin/orders' ? (
<Badge color="error" badgeContent={newOrdersAttention} max={99} invisible={newOrdersAttention === 0}>
{i.icon}
</Badge>
) : (
i.icon
)}
</ListItemIcon>
<ListItemText primary={i.label} />
</ListItemButton>
))}
@@ -26,9 +26,10 @@ import {
postAdminOrderMessage,
setAdminOrderStatus,
} from '@/entities/order/api/admin-order-api'
import { ORDER_STATUS_TRANSITIONS, type OrderStatus } from '@/shared/constants/order'
import { ORDER_STATUSES, getAdminNextOrderStatuses } from '@/shared/constants/order'
import { clearAdminToken, getAdminToken, setAdminToken } from '@/shared/lib/admin-token'
import { formatPriceRub } from '@/shared/lib/format-price'
import { orderStatusLabelRu } from '@/shared/lib/order-status-labels'
type TokenFormState = { token: string }
@@ -37,6 +38,7 @@ export function AdminOrdersPage() {
const [token, setTokenState] = useState<string | null>(() => getAdminToken())
const [q, setQ] = useState('')
const [status, setStatus] = useState('')
const [deliveryType, setDeliveryType] = useState<'delivery' | 'pickup' | ''>('')
const [dialogOpen, setDialogOpen] = useState(false)
const [selectedId, setSelectedId] = useState<string | null>(null)
const [msg, setMsg] = useState('')
@@ -59,8 +61,13 @@ export function AdminOrdersPage() {
}
const ordersQuery = useQuery({
queryKey: ['admin', 'orders', token, { q, status }],
queryFn: () => fetchAdminOrders(token!, { q: q.trim() || undefined, status: status || undefined }),
queryKey: ['admin', 'orders', token, { q, status, deliveryType }],
queryFn: () =>
fetchAdminOrders(token!, {
q: q.trim() || undefined,
status: status || undefined,
deliveryType: deliveryType || undefined,
}),
enabled: Boolean(token),
})
@@ -75,6 +82,7 @@ export function AdminOrdersPage() {
onSuccess: async () => {
await qc.invalidateQueries({ queryKey: ['admin', 'orders'] })
await qc.invalidateQueries({ queryKey: ['admin', 'orders', 'detail'] })
await qc.invalidateQueries({ queryKey: ['admin', 'orders', 'summary'] })
},
})
@@ -96,10 +104,9 @@ export function AdminOrdersPage() {
const detail = orderDetailQuery.data?.item
const nextStatuses = useMemo(() => {
const s = detail?.status
if (!s) return []
return ORDER_STATUS_TRANSITIONS[s as OrderStatus] ?? []
}, [detail?.status])
if (!detail) return []
return getAdminNextOrderStatuses(detail.status, detail.deliveryType ?? 'delivery')
}, [detail])
return (
<Box>
@@ -153,13 +160,31 @@ export function AdminOrdersPage() {
<MenuItem value="">
<em>Все</em>
</MenuItem>
{Object.keys(ORDER_STATUS_TRANSITIONS).map((s) => (
{ORDER_STATUSES.map((s) => (
<MenuItem key={s} value={s}>
{s}
{orderStatusLabelRu(s)}
</MenuItem>
))}
</Select>
</FormControl>
<FormControl size="small" sx={{ minWidth: 220 }}>
<InputLabel id="delivery-type-label">Способ получения</InputLabel>
<Select
labelId="delivery-type-label"
label="Способ получения"
value={deliveryType}
onChange={(e) => {
const v = String(e.target.value)
if (v === '' || v === 'delivery' || v === 'pickup') setDeliveryType(v)
}}
>
<MenuItem value="">
<em>Все</em>
</MenuItem>
<MenuItem value="delivery">Доставка</MenuItem>
<MenuItem value="pickup">Самовывоз</MenuItem>
</Select>
</FormControl>
</Stack>
{ordersQuery.isError && <Alert severity="error">Не удалось загрузить заказы.</Alert>}
@@ -180,7 +205,7 @@ export function AdminOrdersPage() {
<TableRow key={o.id} hover>
<TableCell>{o.id.slice(-8)}</TableCell>
<TableCell>{o.user.email}</TableCell>
<TableCell>{o.status}</TableCell>
<TableCell>{orderStatusLabelRu(o.status)}</TableCell>
<TableCell>{formatPriceRub(o.totalCents)}</TableCell>
<TableCell>{o.itemsCount}</TableCell>
<TableCell align="right">
@@ -210,7 +235,8 @@ export function AdminOrdersPage() {
{detail && (
<Stack spacing={2} sx={{ mt: 1 }}>
<Typography sx={{ fontWeight: 700 }}>
#{detail.id.slice(-8)} · {detail.user.email} · {detail.status} · {formatPriceRub(detail.totalCents)}
#{detail.id.slice(-8)} · {detail.user.email} · {orderStatusLabelRu(detail.status)} ·{' '}
{formatPriceRub(detail.totalCents)}
</Typography>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} sx={{ alignItems: { sm: 'center' } }}>
@@ -232,7 +258,7 @@ export function AdminOrdersPage() {
</MenuItem>
{nextStatuses.map((s) => (
<MenuItem key={s} value={s}>
{s}
{orderStatusLabelRu(s)}
</MenuItem>
))}
</Select>
+1
View File
@@ -1 +1,2 @@
export { AuthCallbackPage } from './ui/AuthCallbackPage'
export { AuthPage } from './ui/AuthPage'
@@ -0,0 +1,28 @@
import { useEffect } from 'react'
import Box from '@mui/material/Box'
import CircularProgress from '@mui/material/CircularProgress'
import Typography from '@mui/material/Typography'
import { useNavigate, useSearchParams } from 'react-router-dom'
import { tokenSet } from '@/shared/model/auth'
export function AuthCallbackPage() {
const [params] = useSearchParams()
const navigate = useNavigate()
useEffect(() => {
const t = params.get('token')
if (t) {
tokenSet(t)
navigate('/', { replace: true })
return
}
navigate('/auth', { replace: true })
}, [navigate, params])
return (
<Box sx={{ py: 6, display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 2 }}>
<CircularProgress />
<Typography color="text.secondary">Завершение входа</Typography>
</Box>
)
}
+28 -1
View File
@@ -9,8 +9,9 @@ import Typography from '@mui/material/Typography'
import { useMutation } from '@tanstack/react-query'
import { useUnit } from 'effector-react'
import { useForm } from 'react-hook-form'
import { useNavigate } from 'react-router-dom'
import { useNavigate, useSearchParams } from 'react-router-dom'
import { apiClient } from '@/shared/api/client'
import { oauthAuthorizeUrl } from '@/shared/lib/oauth-authorize-url'
import { $user, tokenSet } from '@/shared/model/auth'
type AuthResponse = { token: string; user: { id: string; email: string; name?: string | null; phone?: string | null } }
@@ -26,6 +27,8 @@ function getApiErrorMessage(err: unknown): string | null {
export function AuthPage() {
const [message, setMessage] = useState<string | null>(null)
const [oauthError, setOauthError] = useState<string | null>(null)
const [searchParams, setSearchParams] = useSearchParams()
const navigate = useNavigate()
const user = useUnit($user)
const { register, watch } = useForm<{
@@ -45,6 +48,13 @@ export function AuthPage() {
if (user) navigate('/', { replace: true })
}, [navigate, user])
useEffect(() => {
const err = searchParams.get('oauthError')
if (!err) return
setOauthError(err)
setSearchParams({}, { replace: true })
}, [searchParams, setSearchParams])
const requestCode = useMutation({
mutationFn: async () => {
await apiClient.post('auth/request-code', { email })
@@ -94,6 +104,11 @@ export function AuthPage() {
{message}
</Alert>
)}
{oauthError && (
<Alert severity="error" sx={{ mb: 2 }} onClose={() => setOauthError(null)}>
{oauthError}
</Alert>
)}
{errMsg && (
<Alert severity="error" sx={{ mb: 2 }}>
{errMsg}
@@ -101,6 +116,18 @@ export function AuthPage() {
)}
<Stack spacing={2} sx={{ maxWidth: 520 }}>
<Typography variant="subtitle1">Быстрый вход</Typography>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={1.5}>
<Button component="a" href={oauthAuthorizeUrl('vk')} variant="outlined" fullWidth>
Войти через VK
</Button>
<Button component="a" href={oauthAuthorizeUrl('yandex')} variant="outlined" fullWidth>
Войти через Яндекс
</Button>
</Stack>
<Divider>или по email</Divider>
<TextField label="Email" {...register('email')} fullWidth />
<Typography variant="h6">Вариант 1: Email + код</Typography>
+60 -3
View File
@@ -22,6 +22,7 @@ export function CheckoutPage() {
const user = useUnit($user)
const qc = useQueryClient()
const navigate = useNavigate()
const [deliveryType, setDeliveryType] = useState<'delivery' | 'pickup'>('delivery')
const [addressId, setAddressId] = useState('')
const [comment, setComment] = useState('')
@@ -41,7 +42,12 @@ export function CheckoutPage() {
const selectedAddressId = addressId || defaultAddressId
const createMut = useMutation({
mutationFn: () => createOrder({ addressId: selectedAddressId, comment: comment.trim() || null }),
mutationFn: () =>
createOrder({
deliveryType,
addressId: deliveryType === 'delivery' ? selectedAddressId : null,
comment: comment.trim() || null,
}),
onSuccess: async (res) => {
await qc.invalidateQueries({ queryKey: ['me', 'cart'] })
navigate(`/me/orders/${res.orderId}`, { replace: true })
@@ -61,7 +67,11 @@ export function CheckoutPage() {
}
const items = cartQuery.data?.items ?? []
const total = items.reduce((s, x) => s + x.product.priceCents * x.qty, 0)
const itemsSubtotalCents = items.reduce((s, x) => s + x.product.priceCents * x.qty, 0)
const totalQty = items.reduce((s, x) => s + x.qty, 0)
const deliveryFeeCents =
deliveryType === 'delivery' && items.length > 0 ? 50000 * Math.max(1, Math.ceil(totalQty / 2)) : 0
const total = itemsSubtotalCents + deliveryFeeCents
const addresses = addressesQuery.data?.items ?? []
const hasOverLimit = items.some((x) => x.qty > (x.product.inStock ? x.product.quantity : 1))
const hasMadeToOrder = items.some((x) => !x.product.inStock)
@@ -116,6 +126,24 @@ export function CheckoutPage() {
</Alert>
)}
<FormControl size="small" fullWidth>
<InputLabel id="delivery-type-label">Способ получения</InputLabel>
<Select
labelId="delivery-type-label"
label="Способ получения"
value={deliveryType}
onChange={(e) => {
const v = String(e.target.value)
if (v === 'delivery' || v === 'pickup') setDeliveryType(v)
}}
>
<MenuItem value="delivery">Доставка</MenuItem>
<MenuItem value="pickup">Самовывоз</MenuItem>
</Select>
</FormControl>
{deliveryType === 'delivery' && (
<>
<FormControl size="small" fullWidth>
<InputLabel id="addr-label">Адрес доставки</InputLabel>
<Select
@@ -142,6 +170,22 @@ export function CheckoutPage() {
</Alert>
)}
<Alert severity="info">
Стоимость доставки: 500 за каждые 2 единицы (минимум 500 ).
{items.length > 0 && (
<>
{' '}
В этом заказе: {totalQty} шт. доставка {formatPriceRub(deliveryFeeCents)}.
</>
)}
</Alert>
</>
)}
{deliveryType === 'pickup' && (
<Alert severity="info">Самовывоз: адрес доставки не нужен. Мы свяжемся с вами для согласования.</Alert>
)}
<TextField
label="Комментарий к заказу (необязательно)"
value={comment}
@@ -151,12 +195,25 @@ export function CheckoutPage() {
minRows={2}
/>
<Stack spacing={0.25}>
<Typography variant="body2" color="text.secondary">
Товары: {formatPriceRub(itemsSubtotalCents)}
</Typography>
{deliveryType === 'delivery' && (
<Typography variant="body2" color="text.secondary">
Доставка: {formatPriceRub(deliveryFeeCents)}
</Typography>
)}
<Typography variant="h6">Итого: {formatPriceRub(total)}</Typography>
</Stack>
<Button
variant="contained"
disabled={
items.length === 0 || addresses.length === 0 || !selectedAddressId || hasOverLimit || createMut.isPending
items.length === 0 ||
hasOverLimit ||
createMut.isPending ||
(deliveryType === 'delivery' && (addresses.length === 0 || !selectedAddressId))
}
onClick={() => createMut.mutate()}
>
+67 -1
View File
@@ -22,9 +22,11 @@ import { useQuery } from '@tanstack/react-query'
import { fetchCategories, fetchPublicProducts } from '@/entities/product/api/product-api'
import { ProductCard } from '@/entities/product/ui/ProductCard'
import { ToggleCartIcon } from '@/features/cart/toggle-cart-icon'
import { ReviewsBlock } from '@/widgets/reviews-block'
export function HomePage() {
const [categorySlug, setCategorySlug] = useState<string>('')
const [availability, setAvailability] = useState<'all' | 'in_stock' | 'made_to_order'>('all')
const [qInput, setQInput] = useState('')
const [q, setQ] = useState('')
const [moreOpen, setMoreOpen] = useState(false)
@@ -54,6 +56,7 @@ export function HomePage() {
'public',
{
categorySlug: categorySlug || 'all',
availability,
q,
sort,
page,
@@ -69,6 +72,7 @@ export function HomePage() {
}
return fetchPublicProducts({
categorySlug: categorySlug || undefined,
availability: availability === 'all' ? undefined : availability,
q: q || undefined,
sort: sort || '',
page,
@@ -155,6 +159,52 @@ export function HomePage() {
/>
</Stack>
<Paper
variant="outlined"
sx={{
p: 1.5,
borderRadius: 2,
bgcolor: 'background.paper',
display: 'flex',
flexDirection: { xs: 'column', sm: 'row' },
gap: 1.5,
alignItems: { sm: 'center' },
justifyContent: 'space-between',
}}
>
<Box>
<Typography variant="subtitle2">Наличие</Typography>
<Typography variant="caption" color="text.secondary">
Быстрый фильтр по наличию
</Typography>
</Box>
<ToggleButtonGroup
exclusive
size="small"
value={availability}
onChange={(_, v) => {
if (v === 'all' || v === 'in_stock' || v === 'made_to_order') {
setAvailability(v)
setPage(1)
}
}}
sx={{
alignSelf: { xs: 'flex-start', sm: 'auto' },
'& .MuiToggleButton-root': { px: 2, fontWeight: 700, letterSpacing: 0.2, textTransform: 'none' },
'& .MuiToggleButton-root.Mui-selected': {
bgcolor: 'primary.main',
color: 'primary.contrastText',
'&:hover': { bgcolor: 'primary.dark' },
},
}}
>
<ToggleButton value="all">Все</ToggleButton>
<ToggleButton value="in_stock">В наличии</ToggleButton>
<ToggleButton value="made_to_order">Под заказ</ToggleButton>
</ToggleButtonGroup>
</Paper>
<Stack
direction={{ xs: 'column', sm: 'row' }}
spacing={1.5}
@@ -167,6 +217,7 @@ export function HomePage() {
variant="outlined"
onClick={() => {
setCategorySlug('')
setAvailability('all')
setQInput('')
setSort('')
setPriceMinRub('')
@@ -312,11 +363,21 @@ export function HomePage() {
<Grid container spacing={2}>
{products.map((p) => (
<Grid size={{ xs: 12, sm: 6, md: 4 }} key={p.id}>
<ProductCard product={p} mediaHeight={mediaHeight} actions={<ToggleCartIcon productId={p.id} />} />
<ProductCard
product={p}
mediaHeight={mediaHeight}
actions={
<ToggleCartIcon
productId={p.id}
disabledReason={p.inStock && p.quantity === 0 ? 'Нет в наличии' : null}
/>
}
/>
</Grid>
))}
</Grid>
{totalPages > 1 && (
<Stack direction="row" sx={{ mt: 3, justifyContent: 'center' }}>
<Pagination
page={page}
@@ -328,6 +389,11 @@ export function HomePage() {
showLastButton
/>
</Stack>
)}
<Box sx={{ mt: 4 }}>
<ReviewsBlock />
</Box>
</>
)}
</Box>
+26 -1
View File
@@ -6,6 +6,7 @@ import MenuOutlinedIcon from '@mui/icons-material/MenuOutlined'
import PlaceOutlinedIcon from '@mui/icons-material/PlaceOutlined'
import SettingsOutlinedIcon from '@mui/icons-material/SettingsOutlined'
import Alert from '@mui/material/Alert'
import Badge from '@mui/material/Badge'
import Box from '@mui/material/Box'
import Divider from '@mui/material/Divider'
import Drawer from '@mui/material/Drawer'
@@ -18,8 +19,10 @@ import Stack from '@mui/material/Stack'
import { useTheme } from '@mui/material/styles'
import Typography from '@mui/material/Typography'
import useMediaQuery from '@mui/material/useMediaQuery'
import { useQuery } from '@tanstack/react-query'
import { useUnit } from 'effector-react'
import { Navigate, Route, Routes, useLocation, useNavigate } from 'react-router-dom'
import { fetchUnreadMessageCount } from '@/entities/user/api/messages-api'
import { AddressesPage } from '@/pages/me/ui/sections/AddressesPage'
import { MessagesPage } from '@/pages/me/ui/sections/MessagesPage'
import { OrderDetailPage } from '@/pages/me/ui/sections/OrderDetailPage'
@@ -41,6 +44,16 @@ export function MeLayoutPage() {
const isMobile = useMediaQuery(theme.breakpoints.down('md'))
const [mobileOpen, setMobileOpen] = useState(false)
const unreadQuery = useQuery({
queryKey: ['me', 'messages', 'unread-count'],
queryFn: fetchUnreadMessageCount,
enabled: Boolean(user),
refetchInterval: 45_000,
refetchOnWindowFocus: true,
})
const unreadMessages = unreadQuery.data?.count ?? 0
const navItems: NavItem[] = useMemo(
() => [
{ to: '/me/orders', label: 'Заказы', icon: <LocalShippingOutlinedIcon /> },
@@ -81,7 +94,19 @@ export function MeLayoutPage() {
setMobileOpen(false)
}}
>
<ListItemIcon>{i.icon}</ListItemIcon>
<ListItemIcon>
{i.to === '/me/messages' ? (
<Badge
color="error"
badgeContent={unreadMessages > 99 ? '99+' : unreadMessages}
invisible={unreadMessages === 0}
>
{i.icon}
</Badge>
) : (
i.icon
)}
</ListItemIcon>
<ListItemText primary={i.label} />
</ListItemButton>
))}
@@ -1,13 +1,204 @@
import { useEffect, useMemo, useState } from 'react'
import Alert from '@mui/material/Alert'
import Box from '@mui/material/Box'
import Button from '@mui/material/Button'
import Chip from '@mui/material/Chip'
import List from '@mui/material/List'
import ListItem from '@mui/material/ListItem'
import ListItemButton from '@mui/material/ListItemButton'
import ListItemText from '@mui/material/ListItemText'
import Stack from '@mui/material/Stack'
import TextField from '@mui/material/TextField'
import Typography from '@mui/material/Typography'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Link as RouterLink } from 'react-router-dom'
import { fetchMyOrder, postOrderMessage } from '@/entities/order/api/order-api'
import { fetchMyConversations, markOrderMessagesRead } from '@/entities/user/api/messages-api'
import { orderStatusLabelRu } from '@/shared/lib/order-status-labels'
export function MessagesPage() {
const qc = useQueryClient()
const [selectedId, setSelectedId] = useState<string | null>(null)
const [text, setText] = useState('')
const listQuery = useQuery({
queryKey: ['me', 'conversations'],
queryFn: fetchMyConversations,
})
const conversationsList = useMemo(() => listQuery.data?.items ?? [], [listQuery.data?.items])
const activeThreadId = useMemo(() => {
return selectedId ?? conversationsList[0]?.orderId ?? null
}, [selectedId, conversationsList])
const orderQuery = useQuery({
queryKey: ['me', 'orders', activeThreadId],
queryFn: () => fetchMyOrder(activeThreadId!),
enabled: Boolean(activeThreadId),
})
useEffect(() => {
if (!activeThreadId || orderQuery.status !== 'success') return
void (async () => {
await markOrderMessagesRead(activeThreadId).catch(() => undefined)
await qc.invalidateQueries({ queryKey: ['me', 'messages', 'unread-count'] })
await qc.invalidateQueries({ queryKey: ['me', 'conversations'] })
})()
}, [activeThreadId, orderQuery.status, qc])
const msgMut = useMutation({
mutationFn: () => postOrderMessage(activeThreadId!, text.trim()),
onSuccess: async () => {
setText('')
await qc.invalidateQueries({ queryKey: ['me', 'orders', activeThreadId] })
await qc.invalidateQueries({ queryKey: ['me', 'conversations'] })
},
})
const order = orderQuery.data?.item
return (
<Box>
<Typography variant="h4" gutterBottom>
Сообщения
</Typography>
<Typography color="text.secondary">Скоро здесь появятся сообщения и уведомления.</Typography>
<Typography color="text.secondary" variant="body2" sx={{ mb: 2 }}>
Переписка по всем заказам. Последнее сообщение в каждом заказе в списке слева.
</Typography>
{listQuery.isError && <Alert severity="error">Не удалось загрузить переписки.</Alert>}
{listQuery.isSuccess && conversationsList.length === 0 && (
<Alert severity="info">
Пока нет сообщений в заказах. Их отправит администратор или напишите сами на странице заказа.
</Alert>
)}
{conversationsList.length > 0 && (
<Stack direction={{ xs: 'column', md: 'row' }} spacing={2} sx={{ alignItems: 'flex-start' }}>
<Box
sx={{
width: { xs: '100%', md: 320 },
flexShrink: 0,
border: 1,
borderColor: 'divider',
borderRadius: 2,
bgcolor: 'background.paper',
maxHeight: 520,
overflow: 'auto',
}}
>
<List disablePadding>
{conversationsList.map((c) => (
<ListItem
key={c.orderId}
disablePadding
secondaryAction={
c.unreadCount > 0 ? (
<Chip sx={{ mr: 1 }} size="small" color="error" label={String(c.unreadCount)} />
) : null
}
>
<ListItemButton selected={activeThreadId === c.orderId} onClick={() => setSelectedId(c.orderId)}>
<ListItemText
primary={
<Stack direction="row" spacing={1} sx={{ alignItems: 'center', flexWrap: 'wrap' }}>
<Typography component="span" sx={{ fontWeight: 700 }}>
{c.orderId.slice(-6)}
</Typography>
<Typography component="span" variant="caption" color="text.secondary">
· {orderStatusLabelRu(c.status)}
</Typography>
</Stack>
}
secondaryTypographyProps={{
sx: {
mt: 0.5,
overflow: 'hidden',
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
},
}}
secondary={c.preview}
/>
</ListItemButton>
</ListItem>
))}
</List>
</Box>
<Box
sx={{
flexGrow: 1,
minWidth: 0,
border: 1,
borderColor: 'divider',
borderRadius: 2,
p: 2,
bgcolor: 'background.paper',
}}
>
{!activeThreadId && <Typography color="text.secondary">Выберите заказ.</Typography>}
{activeThreadId && orderQuery.isLoading && <Typography>Загрузка чата</Typography>}
{activeThreadId && orderQuery.isError && <Alert severity="error">Не удалось загрузить заказ.</Alert>}
{order && (
<>
<Stack direction="row" sx={{ mb: 2, justifyContent: 'space-between', flexWrap: 'wrap', gap: 1 }}>
<Typography variant="h6">
Чат заказа {order.id.slice(-6)}{' '}
<Typography component="span" variant="body2" color="text.secondary">
({orderStatusLabelRu(order.status)})
</Typography>
</Typography>
<Button component={RouterLink} to={`/me/orders/${order.id}`} size="small" variant="outlined">
Открыть заказ
</Button>
</Stack>
<Stack spacing={1} sx={{ mb: 2, maxHeight: 360, overflow: 'auto' }}>
{order.messages.map((m) => (
<Box
key={m.id}
sx={{
p: 1.25,
borderRadius: 2,
bgcolor: m.authorType === 'admin' ? 'grey.100' : 'primary.50',
border: 1,
borderColor: 'divider',
}}
>
<Typography variant="caption" color="text.secondary">
{m.authorType === 'admin' ? 'Админ' : 'Вы'} · {new Date(m.createdAt).toLocaleString()}
</Typography>
<Typography sx={{ whiteSpace: 'pre-wrap' }}>{m.text}</Typography>
</Box>
))}
{order.messages.length === 0 && <Typography color="text.secondary">Нет сообщений.</Typography>}
</Stack>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} sx={{ alignItems: { sm: 'flex-end' } }}>
<TextField
label="Сообщение"
value={text}
onChange={(e) => setText(e.target.value)}
fullWidth
multiline
minRows={2}
/>
<Button
variant="contained"
sx={{ minWidth: 140 }}
onClick={() => msgMut.mutate()}
disabled={msgMut.isPending || !text.trim()}
>
Отправить
</Button>
</Stack>
</>
)}
</Box>
</Stack>
)}
</Box>
)
}
@@ -1,17 +1,50 @@
import { useMemo, useState } from 'react'
import { useEffect, useMemo, useState } from 'react'
import Alert from '@mui/material/Alert'
import Box from '@mui/material/Box'
import Button from '@mui/material/Button'
import Dialog from '@mui/material/Dialog'
import DialogActions from '@mui/material/DialogActions'
import DialogContent from '@mui/material/DialogContent'
import DialogTitle from '@mui/material/DialogTitle'
import Divider from '@mui/material/Divider'
import Rating from '@mui/material/Rating'
import Stack from '@mui/material/Stack'
import TextField from '@mui/material/TextField'
import Typography from '@mui/material/Typography'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import axios from 'axios'
import { Link as RouterLink, useParams } from 'react-router-dom'
import { fetchMyOrder, payOrderStub, postOrderMessage } from '@/entities/order/api/order-api'
import {
confirmOrderReceived,
fetchMyOrder,
fetchOrderReviewEligibility,
payOrderStub,
postOrderMessage,
} from '@/entities/order/api/order-api'
import { postProductReview } from '@/entities/product/api/reviews-api'
import { markOrderMessagesRead } from '@/entities/user/api/messages-api'
import { formatPriceRub } from '@/shared/lib/format-price'
import { orderStatusLabelRu } from '@/shared/lib/order-status-labels'
function reviewSubmitErrorMessage(err: unknown): string {
if (axios.isAxiosError(err)) {
const status = err.response?.status
const raw = err.response?.data
const apiMsg =
raw && typeof raw === 'object' && 'error' in raw && typeof (raw as { error: unknown }).error === 'string'
? (raw as { error: string }).error
: null
if (status === 409 || apiMsg?.toLowerCase().includes('уже')) {
return 'Вы уже оставляли отзыв на этот товар.'
}
return apiMsg || err.message || 'Не удалось отправить отзыв'
}
if (err instanceof Error) return err.message
return 'Не удалось отправить отзыв'
}
type AddressSnapshot = {
deliveryType?: 'delivery' | 'pickup'
label?: string | null
recipientName?: string
recipientPhone?: string
@@ -23,6 +56,9 @@ export function OrderDetailPage() {
const { id } = useParams()
const qc = useQueryClient()
const [text, setText] = useState('')
const [reviewTarget, setReviewTarget] = useState<{ productId: string; title: string } | null>(null)
const [reviewRating, setReviewRating] = useState<number>(5)
const [reviewText, setReviewText] = useState('')
const orderQuery = useQuery({
queryKey: ['me', 'orders', id],
@@ -32,7 +68,20 @@ export function OrderDetailPage() {
const payMut = useMutation({
mutationFn: () => payOrderStub(id!),
onSuccess: () => void qc.invalidateQueries({ queryKey: ['me', 'orders', id] }),
onSuccess: () =>
Promise.all([
qc.invalidateQueries({ queryKey: ['me', 'orders', id] }),
qc.invalidateQueries({ queryKey: ['me', 'orders'] }),
]),
})
const confirmMut = useMutation({
mutationFn: () => confirmOrderReceived(id!),
onSuccess: () =>
Promise.all([
qc.invalidateQueries({ queryKey: ['me', 'orders', id] }),
qc.invalidateQueries({ queryKey: ['me', 'orders'] }),
]),
})
const msgMut = useMutation({
@@ -40,11 +89,43 @@ export function OrderDetailPage() {
onSuccess: async () => {
setText('')
await qc.invalidateQueries({ queryKey: ['me', 'orders', id] })
await qc.invalidateQueries({ queryKey: ['me', 'conversations'] })
},
})
const order = orderQuery.data?.item
const eligibilityQuery = useQuery({
queryKey: ['me', 'orders', id, 'review-eligibility'],
queryFn: () => fetchOrderReviewEligibility(id!),
enabled: Boolean(id && order?.status === 'DONE'),
})
const reviewMut = useMutation({
mutationFn: async () => {
if (!reviewTarget) return
const t = reviewText.trim()
await postProductReview(reviewTarget.productId, {
rating: reviewRating,
text: t.length ? t : null,
})
},
onSuccess: async () => {
setReviewTarget(null)
setReviewRating(5)
setReviewText('')
await qc.invalidateQueries({ queryKey: ['me', 'orders', id, 'review-eligibility'] })
},
})
useEffect(() => {
if (!id || orderQuery.status !== 'success' || !order) return
void (async () => {
await markOrderMessagesRead(id).catch(() => undefined)
await qc.invalidateQueries({ queryKey: ['me', 'messages', 'unread-count'] })
})()
}, [id, order, orderQuery.status, qc])
const address = useMemo((): AddressSnapshot | null => {
if (!order?.addressSnapshotJson) return null
try {
@@ -63,7 +144,7 @@ export function OrderDetailPage() {
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} sx={{ mb: 2, alignItems: { sm: 'center' } }}>
<Box sx={{ flexGrow: 1 }}>
<Typography variant="h4">Заказ #{order.id.slice(-6)}</Typography>
<Typography color="text.secondary">Статус: {order.status}</Typography>
<Typography color="text.secondary">Статус: {orderStatusLabelRu(order.status)}</Typography>
</Box>
<Button component={RouterLink} to="/me/orders" variant="outlined">
К списку
@@ -89,13 +170,28 @@ export function OrderDetailPage() {
))}
</Stack>
<Divider sx={{ my: 2 }} />
<Stack spacing={0.25}>
<Typography variant="body2" color="text.secondary">
Товары: {formatPriceRub(order.itemsSubtotalCents)}
</Typography>
{order.deliveryType === 'delivery' && (
<Typography variant="body2" color="text.secondary">
Доставка: {formatPriceRub(order.deliveryFeeCents)}
</Typography>
)}
<Typography variant="h6">Итого: {formatPriceRub(order.totalCents)}</Typography>
</Stack>
</Box>
<Box sx={{ border: 1, borderColor: 'divider', borderRadius: 2, p: 2, bgcolor: 'background.paper' }}>
<Typography variant="h6" gutterBottom>
Доставка
Получение
</Typography>
<Typography color="text.secondary" variant="body2" sx={{ mb: 1 }}>
Способ: {order.deliveryType === 'pickup' ? 'Самовывоз' : 'Доставка'}
</Typography>
{order.deliveryType === 'delivery' && (
<>
{address ? (
<>
<Typography sx={{ fontWeight: 700 }}>{address.addressLine}</Typography>
@@ -111,6 +207,13 @@ export function OrderDetailPage() {
) : (
<Typography color="text.secondary">Адрес не распознан.</Typography>
)}
</>
)}
{order.deliveryType === 'pickup' && (
<Typography color="text.secondary">
Адрес доставки не требуется. Мы свяжемся для согласования самовывоза.
</Typography>
)}
{order.comment && (
<Typography color="text.secondary" variant="body2" sx={{ mt: 1 }}>
Комментарий к заказу: {order.comment}
@@ -122,14 +225,81 @@ export function OrderDetailPage() {
<Typography variant="h6" gutterBottom>
Оплата
</Typography>
{order.status === 'PENDING_PAYMENT' && (
<>
<Typography color="text.secondary" variant="body2" sx={{ mb: 1 }}>
Пока это заглушка. Позже подключим реальную оплату.
Пока это заглушка. После нажатия заказ перейдёт в статус «Проверка оплаты».
</Typography>
<Button variant="contained" onClick={() => payMut.mutate()} disabled={payMut.isPending}>
Оплатить (заглушка)
</Button>
</>
)}
{order.status === 'PAYMENT_VERIFICATION' && (
<Typography color="info.main" variant="body2">
Оплата отправлена на проверку. Мы проверим поступление и обновим статус.
</Typography>
)}
{!['PENDING_PAYMENT', 'PAYMENT_VERIFICATION'].includes(order.status) && (
<Typography color="text.secondary" variant="body2">
На этом этапе действий по оплате в этом блоке не требуется.
</Typography>
)}
</Box>
{(order.deliveryType === 'delivery' && order.status === 'SHIPPED') ||
(order.deliveryType === 'pickup' && order.status === 'READY_FOR_PICKUP') ? (
<Box sx={{ border: 1, borderColor: 'divider', borderRadius: 2, p: 2, bgcolor: 'background.paper' }}>
<Typography variant="h6" gutterBottom>
Получение заказа
</Typography>
<Typography color="text.secondary" variant="body2" sx={{ mb: 2 }}>
{order.deliveryType === 'delivery'
? 'Когда забрали посылку у курьера или на пункте выдачи — подтвердите получение.'
: 'Когда забрали заказ самовывозом — подтвердите получение.'}
</Typography>
<Button
variant="contained"
color="success"
onClick={() => confirmMut.mutate()}
disabled={confirmMut.isPending}
>
Подтвердить получение
</Button>
</Box>
) : null}
{order.status === 'DONE' && eligibilityQuery.isSuccess && eligibilityQuery.data.canReview && (
<Box sx={{ border: 1, borderColor: 'divider', borderRadius: 2, p: 2, bgcolor: 'background.paper' }}>
<Typography variant="h6" gutterBottom>
Отзывы
</Typography>
<Typography color="text.secondary" variant="body2" sx={{ mb: 2 }}>
Поделитесь впечатлением о товарах. Отзывы появляются после модерации.
</Typography>
<Stack spacing={1}>
{eligibilityQuery.data.items.map((row) => (
<Stack
key={row.productId}
direction={{ xs: 'column', sm: 'row' }}
spacing={1}
sx={{ alignItems: { sm: 'center' }, justifyContent: 'space-between' }}
>
<Typography sx={{ flexGrow: 1 }}>{row.title}</Typography>
<Button
size="small"
variant="outlined"
disabled={row.hasReview}
onClick={() => setReviewTarget({ productId: row.productId, title: row.title })}
>
{row.hasReview ? 'Отзыв отправлен' : 'Оставить отзыв'}
</Button>
</Stack>
))}
</Stack>
</Box>
)}
<Box sx={{ border: 1, borderColor: 'divider', borderRadius: 2, p: 2, bgcolor: 'background.paper' }}>
<Typography variant="h6" gutterBottom>
Чат по заказу
@@ -175,6 +345,48 @@ export function OrderDetailPage() {
</Stack>
</Box>
</Stack>
<Dialog
open={Boolean(reviewTarget)}
onClose={() => !reviewMut.isPending && setReviewTarget(null)}
fullWidth
maxWidth="sm"
>
<DialogTitle>Отзыв: {reviewTarget?.title}</DialogTitle>
<DialogContent>
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
Оценка
</Typography>
<Rating
value={reviewRating}
onChange={(_, v) => {
if (v !== null) setReviewRating(v)
}}
/>
<TextField
sx={{ mt: 2 }}
label="Комментарий (необязательно)"
value={reviewText}
onChange={(e) => setReviewText(e.target.value)}
fullWidth
multiline
minRows={3}
/>
{reviewMut.isError && (
<Alert severity="error" sx={{ mt: 2 }}>
{reviewSubmitErrorMessage(reviewMut.error)}
</Alert>
)}
</DialogContent>
<DialogActions>
<Button onClick={() => setReviewTarget(null)} disabled={reviewMut.isPending}>
Отмена
</Button>
<Button variant="contained" disabled={reviewMut.isPending} onClick={() => reviewMut.mutate()}>
Отправить
</Button>
</DialogActions>
</Dialog>
</Box>
)
}
@@ -7,6 +7,7 @@ import { useQuery } from '@tanstack/react-query'
import { Link as RouterLink } from 'react-router-dom'
import { fetchMyOrders } from '@/entities/order/api/order-api'
import { formatPriceRub } from '@/shared/lib/format-price'
import { orderStatusLabelRu } from '@/shared/lib/order-status-labels'
export function OrdersPage() {
const ordersQuery = useQuery({
@@ -44,7 +45,7 @@ export function OrdersPage() {
<Box sx={{ flexGrow: 1 }}>
<Typography sx={{ fontWeight: 700 }}>Заказ #{o.id.slice(-6)}</Typography>
<Typography color="text.secondary" variant="body2">
Статус: {o.status} · {o.itemsCount} поз.
Статус: {orderStatusLabelRu(o.status)} · {o.itemsCount} поз.
</Typography>
</Box>
<Typography sx={{ fontWeight: 700 }}>{formatPriceRub(o.totalCents)}</Typography>
@@ -1,11 +1,16 @@
import { useMemo, useState } from 'react'
import CloseIcon from '@mui/icons-material/Close'
import StarRoundedIcon from '@mui/icons-material/StarRounded'
import Alert from '@mui/material/Alert'
import Box from '@mui/material/Box'
import Chip from '@mui/material/Chip'
import Dialog from '@mui/material/Dialog'
import Divider from '@mui/material/Divider'
import IconButton from '@mui/material/IconButton'
import Paper from '@mui/material/Paper'
import Rating from '@mui/material/Rating'
import Skeleton from '@mui/material/Skeleton'
import Stack from '@mui/material/Stack'
import Typography from '@mui/material/Typography'
import { useQuery } from '@tanstack/react-query'
import { useParams } from 'react-router-dom'
@@ -14,8 +19,10 @@ import { Swiper, SwiperSlide } from 'swiper/react'
import 'swiper/css'
import 'swiper/css/navigation'
import { fetchPublicProduct } from '@/entities/product/api/product-api'
import { fetchPublicProductReviews } from '@/entities/product/api/reviews-api'
import { ToggleCartIcon } from '@/features/cart/toggle-cart-icon'
import { formatPriceRub } from '@/shared/lib/format-price'
import { reviewsCountRu } from '@/shared/lib/reviews-count-ru'
export function ProductPage() {
const { id } = useParams()
@@ -28,6 +35,12 @@ export function ProductPage() {
enabled: Boolean(id),
})
const reviewsQuery = useQuery({
queryKey: ['products', 'public', id, 'reviews', { page: 1, pageSize: 30 }],
queryFn: () => fetchPublicProductReviews(id!, { page: 1, pageSize: 30 }),
enabled: Boolean(id),
})
const imageUrls = useMemo(() => {
const p = productQuery.data
if (!p) return []
@@ -150,6 +163,73 @@ export function ProductPage() {
) : (
<Typography color="text.secondary">Описание появится позже.</Typography>
)}
<Divider sx={{ my: 2 }} />
<Typography variant="h6" sx={{ mb: 1 }}>
Отзывы
</Typography>
{p.reviewsSummary && p.reviewsSummary.approvedReviewCount > 0 && (
<Stack direction="row" spacing={1} sx={{ alignItems: 'center', mb: 2 }}>
<Rating
value={p.reviewsSummary.avgRating ?? 0}
readOnly
precision={0.25}
icon={<StarRoundedIcon fontSize="inherit" />}
emptyIcon={<StarRoundedIcon fontSize="inherit" />}
/>
<Typography variant="body2" color="text.secondary">
{reviewsCountRu(p.reviewsSummary.approvedReviewCount)}
</Typography>
</Stack>
)}
{reviewsQuery.isLoading && <Typography color="text.secondary">Загрузка отзывов</Typography>}
{reviewsQuery.isError && <Alert severity="warning">Не удалось загрузить отзывы.</Alert>}
{reviewsQuery.data && reviewsQuery.data.total === 0 && (
<Typography color="text.secondary">Пока нет опубликованных отзывов на этот товар.</Typography>
)}
{reviewsQuery.data && reviewsQuery.data.items.length > 0 && (
<Stack spacing={1.25}>
{reviewsQuery.data.items.map((rv) => {
const body = typeof rv.text === 'string' && rv.text.trim() ? rv.text.trim() : null
return (
<Paper key={rv.id} variant="outlined" sx={{ p: 1.5, borderRadius: 2 }}>
<Stack spacing={0.75}>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={1} sx={{ justifyContent: 'space-between' }}>
<Typography sx={{ fontWeight: 700 }}>{rv.authorDisplay}</Typography>
<Typography variant="caption" color="text.secondary">
{new Date(rv.createdAt).toLocaleString('ru-RU')}
</Typography>
</Stack>
<Rating
value={rv.rating}
readOnly
size="small"
icon={<StarRoundedIcon fontSize="inherit" />}
emptyIcon={<StarRoundedIcon fontSize="inherit" />}
/>
{body ? (
<Typography variant="body2" color="text.secondary" sx={{ whiteSpace: 'pre-wrap' }}>
{body}
</Typography>
) : (
<Typography variant="caption" color="text.secondary">
Без текстового комментария.
</Typography>
)}
</Stack>
</Paper>
)
})}
{reviewsQuery.data.total > reviewsQuery.data.items.length && (
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 1 }}>
Всего {reviewsCountRu(reviewsQuery.data.total)} ниже показаны последние{' '}
{reviewsQuery.data.items.length}.
</Typography>
)}
</Stack>
)}
</Box>
<Dialog fullScreen open={viewerOpen} onClose={() => setViewerOpen(false)}>
+5
View File
@@ -2,3 +2,8 @@
export const apiBaseURL = import.meta.env.VITE_API_URL ?? '/api'
export const STORE_NAME = 'Рукодельная лавка'
/** Демо-контакты для футера; при необходимости задайте через VITE_* в `.env`. */
export const STORE_EMAIL = import.meta.env.VITE_STORE_EMAIL ?? 'hello@example.com'
export const STORE_PHONE = import.meta.env.VITE_STORE_PHONE ?? '+7 (900) 000-00-00'
export const STORE_SOCIAL_NOTE = import.meta.env.VITE_STORE_SOCIAL_NOTE ?? 'Соцсети: укажите ссылки при публикации'
+20 -11
View File
@@ -1,28 +1,37 @@
export const ORDER_STATUSES = [
'DRAFT',
'PENDING_PAYMENT',
'PAYMENT_VERIFICATION',
'PAID',
'IN_PROGRESS',
'SHIPPED',
'READY_FOR_PICKUP',
'DONE',
'CANCELLED',
] as const
export type OrderStatus = (typeof ORDER_STATUSES)[number]
export const ORDER_STATUS_TRANSITIONS: Record<OrderStatus, OrderStatus[]> = {
DRAFT: ['PENDING_PAYMENT', 'CANCELLED'],
PENDING_PAYMENT: ['PAID', 'CANCELLED'],
PAID: ['IN_PROGRESS', 'CANCELLED'],
IN_PROGRESS: ['SHIPPED', 'CANCELLED'],
SHIPPED: ['DONE'],
DONE: [],
CANCELLED: [],
/** Следующие статусы, доступные админу (смена через PATCH). */
export function getAdminNextOrderStatuses(status: string, deliveryType: 'delivery' | 'pickup'): OrderStatus[] {
switch (status) {
case 'DRAFT':
return ['PENDING_PAYMENT', 'CANCELLED']
case 'PENDING_PAYMENT':
return ['CANCELLED']
case 'PAYMENT_VERIFICATION':
return ['PAID', 'CANCELLED']
case 'PAID':
return ['IN_PROGRESS', 'CANCELLED']
case 'IN_PROGRESS':
if (deliveryType === 'delivery') return ['SHIPPED', 'CANCELLED']
return ['READY_FOR_PICKUP', 'CANCELLED']
default:
return []
}
}
export function canTransitionOrderStatus(from: string, to: string): boolean {
if (from === to) return true
const f = from as OrderStatus
const list = ORDER_STATUS_TRANSITIONS[f]
return Array.isArray(list) ? list.includes(to as OrderStatus) : false
return getAdminNextOrderStatuses(from, 'delivery').includes(to as OrderStatus)
}
+13
View File
@@ -1,13 +1,26 @@
const KEY = 'craftshop_admin_token'
const TOKEN_EVENT = 'craftshop_admin_token_change'
export function getAdminToken(): string | null {
return sessionStorage.getItem(KEY)
}
function notifyTokenListeners(): void {
window.dispatchEvent(new Event(TOKEN_EVENT))
}
/** Подписаться на смену токена (в т. ч. после setAdminToken). */
export function subscribeAdminTokenChange(cb: () => void): () => void {
window.addEventListener(TOKEN_EVENT, cb)
return () => window.removeEventListener(TOKEN_EVENT, cb)
}
export function setAdminToken(token: string): void {
sessionStorage.setItem(KEY, token)
notifyTokenListeners()
}
export function clearAdminToken(): void {
sessionStorage.removeItem(KEY)
notifyTokenListeners()
}
@@ -0,0 +1,7 @@
import { apiBaseURL } from '@/shared/config'
/** Абсолютный или корневой путь начала OAuth на бэкенде (редирект браузера). */
export function oauthAuthorizeUrl(provider: 'vk' | 'yandex'): string {
const base = apiBaseURL.replace(/\/$/, '')
return `${base}/auth/oauth/${provider}`
}
@@ -0,0 +1,15 @@
/** Человекочитаемые подписи к кодам статуса заказа */
export function orderStatusLabelRu(code: string): string {
const map: Record<string, string> = {
DRAFT: 'Черновик',
PENDING_PAYMENT: 'Ожидает оплаты',
PAYMENT_VERIFICATION: 'Проверка оплаты',
PAID: 'Оплачен',
IN_PROGRESS: 'В работе',
SHIPPED: 'Отправлен',
READY_FOR_PICKUP: 'Готово к получению',
DONE: 'Завершён',
CANCELLED: 'Отменён',
}
return map[code] ?? code
}
+12
View File
@@ -0,0 +1,12 @@
/** Склонение «N отзыв(ов…)» для целых n ≥ 0. */
export function reviewsCountRu(n: number): string {
const x = Math.abs(Math.floor(n))
const mod100 = x % 100
const mod10 = x % 10
let word = 'отзывов'
if (mod100 < 11 || mod100 > 14) {
if (mod10 === 1) word = 'отзыв'
else if (mod10 >= 2 && mod10 <= 4) word = 'отзыва'
}
return `${x}\u00a0${word}`
}
+3
View File
@@ -2,6 +2,9 @@
interface ImportMetaEnv {
readonly VITE_API_URL?: string
readonly VITE_STORE_EMAIL?: string
readonly VITE_STORE_PHONE?: string
readonly VITE_STORE_SOCIAL_NOTE?: string
}
interface ImportMeta {
@@ -0,0 +1 @@
export { ReviewsBlock } from './ui/ReviewsBlock'
@@ -0,0 +1,119 @@
import StarRoundedIcon from '@mui/icons-material/StarRounded'
import Alert from '@mui/material/Alert'
import Avatar from '@mui/material/Avatar'
import Box from '@mui/material/Box'
import Paper from '@mui/material/Paper'
import Rating from '@mui/material/Rating'
import Skeleton from '@mui/material/Skeleton'
import Stack from '@mui/material/Stack'
import Typography from '@mui/material/Typography'
import { useQuery } from '@tanstack/react-query'
import { Link as RouterLink } from 'react-router-dom'
import { fetchLatestApprovedReviews } from '@/entities/product/api/reviews-api'
function initials(display: string) {
const s = display.trim()
if (!s) return '?'
return s.slice(0, 1).toUpperCase()
}
function formatReviewDate(iso: string): string {
try {
return new Date(iso).toLocaleDateString('ru-RU', { day: '2-digit', month: '2-digit', year: 'numeric' })
} catch {
return ''
}
}
export function ReviewsBlock() {
const q = useQuery({
queryKey: ['reviews', 'latest', 5],
queryFn: () => fetchLatestApprovedReviews(5),
})
const items = !q.isLoading && !q.isError && q.data ? q.data.items : []
return (
<Paper variant="outlined" sx={{ p: { xs: 2, sm: 3 }, borderRadius: 2, bgcolor: 'background.paper' }}>
<Stack spacing={0.75} sx={{ mb: 2 }}>
<Typography variant="h5">Отзывы</Typography>
<Typography variant="body2" color="text.secondary">
Последние одобренные отзывы о товарах
</Typography>
</Stack>
{q.isLoading && (
<Stack spacing={2}>
{[1, 2, 3].map((i) => (
<Skeleton key={i} variant="rounded" height={92} sx={{ borderRadius: 2 }} />
))}
</Stack>
)}
{q.isError && <Alert severity="error">Не удалось загрузить отзывы.</Alert>}
{!q.isLoading && !q.isError && q.data && items.length === 0 && (
<Typography color="text.secondary">Пока нет опубликованных отзывов о товарах.</Typography>
)}
{items.length > 0 && (
<Stack spacing={2}>
{items.map((r, i) => {
const zebra = i % 2 === 0
const text = typeof r.text === 'string' && r.text.trim() ? r.text.trim() : 'Без комментария'
return (
<Paper
key={r.id}
variant={zebra ? undefined : 'outlined'}
elevation={zebra ? 0 : undefined}
sx={{
p: { xs: 1.75, sm: 2 },
borderRadius: 2,
...(zebra ? { bgcolor: 'action.hover' } : {}),
}}
>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2}>
<Stack direction="row" spacing={1.5} sx={{ minWidth: { sm: 220 }, alignItems: 'center' }}>
<Avatar sx={{ bgcolor: 'primary.main', color: 'primary.contrastText', fontWeight: 800 }}>
{initials(r.authorDisplay)}
</Avatar>
<Box>
<Typography sx={{ fontWeight: 800, lineHeight: 1.15 }}>{r.authorDisplay}</Typography>
<Stack direction="row" spacing={1} sx={{ alignItems: 'center', flexWrap: 'wrap' }}>
<Rating
value={r.rating}
readOnly
size="small"
icon={<StarRoundedIcon fontSize="inherit" />}
emptyIcon={<StarRoundedIcon fontSize="inherit" />}
/>
<Typography variant="caption" color="text.secondary">
{formatReviewDate(r.createdAt)}
</Typography>
</Stack>
<Typography
variant="caption"
component={RouterLink}
to={`/products/${r.productId}`}
sx={{
display: 'block',
mt: 0.25,
color: 'primary.main',
textDecoration: 'none',
'&:hover': { textDecoration: 'underline' },
}}
>
{r.productTitle}
</Typography>
</Box>
</Stack>
<Typography color="text.secondary" sx={{ whiteSpace: 'pre-wrap', flex: 1 }}>
{text}
</Typography>
</Stack>
</Paper>
)
})}
</Stack>
)}
</Paper>
)
}
+16 -1
View File
@@ -1,4 +1,19 @@
DATABASE_URL="file:./dev.db"
PORT=3333
ADMIN_API_TOKEN=замените-на-секрет
# CORS_ORIGIN=http://localhost:5173
JWT_SECRET=замените-на-секрет-jwt
# Разрешённый Origin фронта (через запятую при нескольких)
# CORS_ORIGIN=http://127.0.0.1:5173
# Публичные URL для OAuth redirect (локально обычно так):
SERVER_PUBLIC_URL=http://127.0.0.1:3333
CLIENT_PUBLIC_URL=http://127.0.0.1:5173
# VK OAuth: в кабинете VK задать redirect URI = SERVER_PUBLIC_URL + /api/auth/oauth/vk/callback
VK_CLIENT_ID=
VK_CLIENT_SECRET=
# Yandex OAuth: redirect URI = SERVER_PUBLIC_URL + /api/auth/oauth/yandex/callback
YANDEX_CLIENT_ID=
YANDEX_CLIENT_SECRET=
@@ -0,0 +1,25 @@
-- RedefineTables
PRAGMA defer_foreign_keys=ON;
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_Order" (
"id" TEXT NOT NULL PRIMARY KEY,
"status" TEXT NOT NULL DEFAULT 'DRAFT',
"deliveryType" TEXT NOT NULL DEFAULT 'delivery',
"itemsSubtotalCents" INTEGER NOT NULL DEFAULT 0,
"deliveryFeeCents" INTEGER NOT NULL DEFAULT 0,
"totalCents" INTEGER NOT NULL DEFAULT 0,
"currency" TEXT NOT NULL DEFAULT 'RUB',
"addressSnapshotJson" TEXT,
"comment" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
"userId" TEXT NOT NULL,
CONSTRAINT "Order_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
INSERT INTO "new_Order" ("addressSnapshotJson", "comment", "createdAt", "currency", "id", "status", "totalCents", "updatedAt", "userId") SELECT "addressSnapshotJson", "comment", "createdAt", "currency", "id", "status", "totalCents", "updatedAt", "userId" FROM "Order";
DROP TABLE "Order";
ALTER TABLE "new_Order" RENAME TO "Order";
CREATE INDEX "Order_userId_createdAt_idx" ON "Order"("userId", "createdAt");
CREATE INDEX "Order_status_updatedAt_idx" ON "Order"("status", "updatedAt");
PRAGMA foreign_keys=ON;
PRAGMA defer_foreign_keys=OFF;
@@ -0,0 +1,35 @@
-- CreateTable
CREATE TABLE "UserOrderMessageReadState" (
"id" TEXT NOT NULL PRIMARY KEY,
"lastReadAt" DATETIME NOT NULL DEFAULT '1970-01-01 00:00:00 +00:00',
"updatedAt" DATETIME NOT NULL,
"userId" TEXT NOT NULL,
"orderId" TEXT NOT NULL,
CONSTRAINT "UserOrderMessageReadState_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "UserOrderMessageReadState_orderId_fkey" FOREIGN KEY ("orderId") REFERENCES "Order" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "OAuthAccount" (
"id" TEXT NOT NULL PRIMARY KEY,
"provider" TEXT NOT NULL,
"providerUserId" TEXT NOT NULL,
"accessToken" TEXT,
"refreshToken" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
"userId" TEXT NOT NULL,
CONSTRAINT "OAuthAccount_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateIndex
CREATE INDEX "UserOrderMessageReadState_userId_idx" ON "UserOrderMessageReadState"("userId");
-- CreateIndex
CREATE UNIQUE INDEX "UserOrderMessageReadState_userId_orderId_key" ON "UserOrderMessageReadState"("userId", "orderId");
-- CreateIndex
CREATE INDEX "OAuthAccount_userId_idx" ON "OAuthAccount"("userId");
-- CreateIndex
CREATE UNIQUE INDEX "OAuthAccount_provider_providerUserId_key" ON "OAuthAccount"("provider", "providerUserId");
+40 -1
View File
@@ -69,6 +69,23 @@ model User {
cartItems CartItem[]
orders Order[]
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 {
@@ -90,9 +107,13 @@ model Order {
id String @id @default(cuid())
/// Статус заказа (валидация переходов на уровне API)
status String @default("DRAFT")
/// 'delivery' | 'pickup'
deliveryType String @default("delivery")
itemsSubtotalCents Int @default(0)
deliveryFeeCents Int @default(0)
totalCents Int @default(0)
currency String @default("RUB")
addressSnapshotJson String
addressSnapshotJson String?
comment String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -102,6 +123,7 @@ model Order {
items OrderItem[]
messages OrderMessage[]
messageReadStates UserOrderMessageReadState[]
@@index([userId, createdAt])
@@index([status, updatedAt])
@@ -175,6 +197,23 @@ model ShippingAddress {
@@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 {
id String @id @default(cuid())
email String
+2
View File
@@ -8,6 +8,7 @@ import path from 'node:path'
import { registerAuth } from './plugins/auth.js'
import { registerApiRoutes } from './routes/api.js'
import { registerAuthRoutes } from './routes/auth.js'
import { registerOAuthSocialRoutes } from './routes/oauth-social.js'
const port = Number(process.env.PORT) || 3333
const origin = (process.env.CORS_ORIGIN ?? '')
@@ -49,6 +50,7 @@ fastify.decorate('authenticate', async function authenticate(request, reply) {
registerAuth(fastify)
await registerAuthRoutes(fastify)
await registerOAuthSocialRoutes(fastify)
await registerApiRoutes(fastify)
fastify.get('/health', async () => ({ ok: true }))
+35 -12
View File
@@ -1,26 +1,49 @@
export const ORDER_STATUSES = [
'DRAFT',
'PENDING_PAYMENT',
'PAYMENT_VERIFICATION',
'PAID',
'IN_PROGRESS',
'SHIPPED',
'READY_FOR_PICKUP',
'DONE',
'CANCELLED',
]
export const ORDER_STATUS_TRANSITIONS = {
DRAFT: new Set(['PENDING_PAYMENT', 'CANCELLED']),
PENDING_PAYMENT: new Set(['PAID', 'CANCELLED']),
PAID: new Set(['IN_PROGRESS', 'CANCELLED']),
IN_PROGRESS: new Set(['SHIPPED', 'CANCELLED']),
SHIPPED: new Set(['DONE']),
DONE: new Set([]),
CANCELLED: new Set([]),
/**
* Переходы, которые делает админ через PATCH /api/admin/orders/:id/status
* (подтверждение получения пользователем — отдельный эндпоинт).
*/
export function canTransitionAdminOrderStatus(order, next) {
const from = order.status
const dt = order.deliveryType
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) {
if (from === to) return true
const allowed = ORDER_STATUS_TRANSITIONS[from]
return Boolean(allowed?.has(to))
return canTransitionAdminOrderStatus({ status: from, deliveryType: 'delivery' }, to)
}
+13
View File
@@ -0,0 +1,13 @@
/** Публичное отображение автора отзыва (без «голого» email). */
export function publicReviewAuthorDisplay(user) {
if (!user || typeof user !== 'object') return 'Покупатель'
const name = typeof user.name === 'string' ? user.name.trim() : ''
if (name) return name
const email = typeof user.email === 'string' ? user.email.trim() : ''
const at = email.indexOf('@')
if (at <= 0) return 'Покупатель'
const local = email.slice(0, at)
const domain = email.slice(at + 1)
const masked = local.length <= 1 ? '*' : `${local.slice(0, 1)}***`
return `${masked}@${domain}`
}
+6 -2
View File
@@ -43,10 +43,14 @@ export function materialsFromDb(materials) {
}
}
export function mapProductForApi(p) {
return {
export function mapProductForApi(p, reviewsSummary = null) {
const base = {
...p,
materials: materialsFromDb(p.materials),
}
if (reviewsSummary && typeof reviewsSummary === 'object') {
base.reviewsSummary = reviewsSummary
}
return base
}
+22 -2
View File
@@ -1,13 +1,26 @@
import { prisma } from '../../lib/prisma.js'
import { canTransitionOrderStatus } from '../../lib/order-status.js'
import { canTransitionAdminOrderStatus } from '../../lib/order-status.js'
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(
'/api/admin/orders',
{ preHandler: [fastify.verifyAdmin] },
async (request, reply) => {
const status = typeof request.query?.status === 'string' ? request.query.status.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 pageParsed = typeof pageRaw === 'string' ? Number(pageRaw) : Number(pageRaw)
@@ -20,6 +33,12 @@ export async function registerAdminOrderRoutes(fastify) {
const where = {}
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) {
where.OR = [{ id: { contains: q } }, { user: { email: { contains: q } } }]
}
@@ -37,6 +56,7 @@ export async function registerAdminOrderRoutes(fastify) {
items: items.map((o) => ({
id: o.id,
status: o.status,
deliveryType: o.deliveryType,
totalCents: o.totalCents,
currency: o.currency,
createdAt: o.createdAt,
@@ -79,7 +99,7 @@ export async function registerAdminOrderRoutes(fastify) {
const existing = await prisma.order.findUnique({ where: { id } })
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}` })
}
+79 -4
View File
@@ -1,5 +1,63 @@
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 } = {}) {
fastify.get('/api/categories', async () => {
return prisma.category.findMany({ orderBy: { sort: 'asc' } })
@@ -9,6 +67,8 @@ export async function registerPublicCatalogRoutes(fastify, { mapProductForApi }
const { categorySlug } = request.query
const qRaw = request.query?.q
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 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 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) {
where.category = { slug: categorySlug }
}
if (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)
if (applyPriceFilter && (priceMin !== null || priceMax !== null)) {
@@ -64,20 +132,27 @@ export async function registerPublicCatalogRoutes(fastify, { mapProductForApi }
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) => {
const { id } = request.params
const product = await prisma.product.findFirst({
where: { id, published: true, quantity: { gt: 0 } },
where: { id, published: true },
include: { category: true, images: { orderBy: { sort: 'asc' } } },
})
if (!product) {
reply.code(404).send({ error: 'Товар не найден' })
return
}
return mapProductForApi(product)
const summaries = await approvedReviewSummariesForProducts([product.id])
return mapProductForApi(product, summaries.get(product.id) ?? EMPTY_REVIEWS_SUMMARY)
})
}
+40 -2
View File
@@ -1,6 +1,36 @@
import { publicReviewAuthorDisplay } from '../../lib/review-display.js'
import { prisma } from '../../lib/prisma.js'
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) => {
const { id } = request.params
@@ -18,14 +48,22 @@ export async function registerPublicReviewRoutes(fastify) {
const where = { productId: id, status: 'approved' }
const total = await prisma.review.count({ where })
const items = await prisma.review.findMany({
const rawItems = await prisma.review.findMany({
where,
include: { user: { select: { id: true, name: true, email: true } } },
include: { user: { select: { email: true, name: true } } },
orderBy: { createdAt: 'desc' },
skip: (page - 1) * 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 }
})
+170 -10
View File
@@ -454,14 +454,26 @@ export async function registerAuthRoutes(fastify) {
{ preHandler: [fastify.authenticate] },
async (request, reply) => {
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 commentRaw = request.body?.comment
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 (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({
where: { userId },
@@ -483,8 +495,17 @@ export async function registerAuthRoutes(fastify) {
priceCentsSnapshot: ci.product.priceCents,
}))
const totalCents = itemsPayload.reduce((sum, i) => sum + i.priceCentsSnapshot * i.qty, 0)
const addressSnapshotJson = JSON.stringify({
const itemsSubtotalCents = itemsPayload.reduce((sum, i) => sum + i.priceCentsSnapshot * i.qty, 0)
const totalQty = itemsPayload.reduce((sum, i) => sum + i.qty, 0)
const deliveryFeeCents =
deliveryType === 'delivery' ? 50000 * Math.max(1, Math.ceil(totalQty / 2)) : 0
const totalCents = itemsSubtotalCents + deliveryFeeCents
const addressSnapshotJson =
deliveryType === 'pickup'
? JSON.stringify({ deliveryType: 'pickup' })
: JSON.stringify({
deliveryType: 'delivery',
id: address.id,
label: address.label,
recipientName: address.recipientName,
@@ -509,16 +530,15 @@ export async function registerAuthRoutes(fastify) {
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({
data: {
userId,
status: 'PENDING_PAYMENT',
deliveryType,
itemsSubtotalCents,
deliveryFeeCents,
totalCents,
currency: 'RUB',
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(
'/api/me/orders/:id/pay',
{ preHandler: [fastify.authenticate] },
@@ -620,11 +722,69 @@ export async function registerAuthRoutes(fastify) {
const { id } = request.params
const order = await prisma.order.findFirst({ where: { id, userId } })
if (!order) return reply.code(404).send({ error: 'Заказ не найден' })
// Заглушка: пока ничего не оплачиваем, просто подтверждаем намерение оплатить
let nextStatus = order.status
if (order.status === 'DRAFT') {
await prisma.order.update({ where: { id }, data: { status: 'PENDING_PAYMENT' } })
nextStatus = 'PENDING_PAYMENT'
} else if (order.status === 'PENDING_PAYMENT') {
await prisma.order.update({ where: { id }, data: { status: 'PAYMENT_VERIFICATION' } })
nextStatus = 'PAYMENT_VERIFICATION'
}
return { ok: true, status: order.status === 'DRAFT' ? 'PENDING_PAYMENT' : order.status }
return { ok: true, status: nextStatus }
},
)
fastify.get(
'/api/me/orders/:id/review-eligibility',
{ preHandler: [fastify.authenticate] },
async (request, reply) => {
const userId = request.user.sub
const { id } = request.params
const order = await prisma.order.findFirst({ where: { id, userId }, include: { items: true } })
if (!order) return reply.code(404).send({ error: 'Заказ не найден' })
if (order.status !== 'DONE') {
return { canReview: false, items: [] }
}
const uniq = new Map()
for (const it of order.items) {
if (!uniq.has(it.productId)) {
uniq.set(it.productId, { productId: it.productId, title: it.titleSnapshot })
}
}
const productIds = [...uniq.keys()]
const existing = await prisma.review.findMany({
where: { userId, productId: { in: productIds } },
select: { productId: true },
})
const reviewed = new Set(existing.map((r) => r.productId))
return {
canReview: true,
items: [...uniq.values()].map((x) => ({
...x,
hasReview: reviewed.has(x.productId),
})),
}
},
)
fastify.post(
'/api/me/orders/:id/confirm-received',
{ preHandler: [fastify.authenticate] },
async (request, reply) => {
const userId = request.user.sub
const { id } = request.params
const order = await prisma.order.findFirst({ where: { id, userId } })
if (!order) return reply.code(404).send({ error: 'Заказ не найден' })
const okDelivery = order.deliveryType === 'delivery' && order.status === 'SHIPPED'
const okPickup = order.deliveryType === 'pickup' && order.status === 'READY_FOR_PICKUP'
if (!okDelivery && !okPickup) {
return reply.code(409).send({ error: 'Сейчас нельзя подтвердить получение заказа' })
}
await prisma.order.update({ where: { id }, data: { status: 'DONE' } })
return { ok: true, status: 'DONE' }
},
)
}
+244
View File
@@ -0,0 +1,244 @@
import { normalizeEmail } from '../lib/auth.js'
import { prisma } from '../lib/prisma.js'
function clientRedirect(fastify, reply, token) {
const base = process.env.CLIENT_PUBLIC_URL || 'http://127.0.0.1:5173'
const url = `${base.replace(/\/$/, '')}/auth/callback?token=${encodeURIComponent(token)}`
return reply.redirect(url)
}
function oauthErrorRedirect(reply, msg) {
const base = process.env.CLIENT_PUBLIC_URL || 'http://127.0.0.1:5173'
const url = `${base.replace(/\/$/, '')}/auth?oauthError=${encodeURIComponent(msg)}`
return reply.redirect(url)
}
async function issueUserJwt(fastify, userId, email) {
return fastify.jwt.sign({ sub: userId, email })
}
async function findOrCreateUserFromOAuth({ provider, providerUserId, accessToken, suggestedEmail }) {
const existingLink = await prisma.oauthAccount.findUnique({
where: { provider_providerUserId: { provider, providerUserId } },
include: { user: true },
})
if (existingLink?.user) {
if (accessToken !== undefined) {
await prisma.oauthAccount.update({
where: { provider_providerUserId: { provider, providerUserId } },
data: { accessToken },
})
}
return existingLink.user
}
const trimmed = typeof suggestedEmail === 'string' ? suggestedEmail.trim() : ''
const norm = trimmed ? normalizeEmail(trimmed) : null
let user = norm ? await prisma.user.findUnique({ where: { email: norm } }) : null
if (user) {
await prisma.oauthAccount.create({
data: { provider, providerUserId: String(providerUserId), userId: user.id, accessToken },
})
return user
}
let email = norm || `${provider}_${providerUserId}@oauth.craftshop.local`
let n = 0
while (await prisma.user.findUnique({ where: { email } })) {
n += 1
email = `${provider}_${providerUserId}_${n}@oauth.craftshop.local`
}
user = await prisma.user.create({ data: { email } })
await prisma.oauthAccount.create({
data: { provider, providerUserId: String(providerUserId), userId: user.id, accessToken },
})
return user
}
export async function registerOAuthSocialRoutes(fastify) {
const serverPublic = process.env.SERVER_PUBLIC_URL || 'http://127.0.0.1:3333'
/** --- VK --- */
fastify.get('/api/auth/oauth/vk', async (_request, reply) => {
const clientId = process.env.VK_CLIENT_ID
const clientSecret = process.env.VK_CLIENT_SECRET
if (!clientId || !clientSecret) return reply.code(503).send({ error: 'VK OAuth не настроен (нет VK_* в env)' })
const redirectUri = `${serverPublic}/api/auth/oauth/vk/callback`
const state = fastify.jwt.sign({ oauth: 'vk' }, { expiresIn: '15m' })
const url = new URL('https://oauth.vk.com/authorize')
url.searchParams.set('client_id', clientId)
url.searchParams.set('display', 'page')
url.searchParams.set('redirect_uri', redirectUri)
url.searchParams.set('scope', 'email')
url.searchParams.set('response_type', 'code')
url.searchParams.set('v', '5.199')
url.searchParams.set('state', state)
return reply.redirect(url.toString())
})
fastify.get('/api/auth/oauth/vk/callback', async (request, reply) => {
const query = request.query ?? {}
if (query.error || query.error_description) {
return oauthErrorRedirect(reply, String(query.error_description || query.error || 'ошибка VK'))
}
try {
const state = typeof query.state === 'string' ? query.state : ''
fastify.jwt.verify(state || '')
} catch {
return oauthErrorRedirect(reply, 'Недействительный state OAuth')
}
const code = typeof query.code === 'string' ? query.code.trim() : ''
if (!code) return oauthErrorRedirect(reply, 'Не получен код от VK')
const clientId = process.env.VK_CLIENT_ID
const clientSecret = process.env.VK_CLIENT_SECRET
const redirectUri = `${serverPublic}/api/auth/oauth/vk/callback`
const tokenUrl = new URL('https://oauth.vk.com/access_token')
tokenUrl.searchParams.set('client_id', clientId)
tokenUrl.searchParams.set('client_secret', clientSecret)
tokenUrl.searchParams.set('redirect_uri', redirectUri)
tokenUrl.searchParams.set('code', code)
const tokenRes = await fetch(tokenUrl.toString())
const tokenBody = await tokenRes.json()
if (tokenBody?.error_description || tokenBody?.error || !tokenRes.ok) {
return oauthErrorRedirect(reply, tokenBody?.error_description || tokenBody?.error || 'Не удалось обменять код VK')
}
const vkUserId = tokenBody?.user_id
const accessTokenVk = tokenBody?.access_token
let emailSuggestion = typeof tokenBody?.email === 'string' ? tokenBody.email : null
let firstName = null
let lastName = null
try {
if (accessTokenVk && vkUserId) {
const u = new URL('https://api.vk.com/method/users.get')
u.searchParams.set('access_token', accessTokenVk)
u.searchParams.set('users_ids', String(vkUserId))
u.searchParams.set('fields', 'photo_50')
u.searchParams.set('v', '5.199')
const profRes = await fetch(u.toString())
const prof = await profRes.json()
const u0 = prof?.response?.[0]
if (u0) {
firstName = u0.first_name ?? null
lastName = u0.last_name ?? null
}
}
} catch {
// ignore profile extras
}
const user = await findOrCreateUserFromOAuth({
provider: 'vk',
providerUserId: String(vkUserId),
accessToken: accessTokenVk ?? null,
suggestedEmail: emailSuggestion,
})
if (firstName || lastName) {
const name = [firstName, lastName].filter(Boolean).join(' ').trim()
if (name && !user.name) {
await prisma.user.update({ where: { id: user.id }, data: { name } })
}
}
const token = await issueUserJwt(fastify, user.id, user.email)
return clientRedirect(fastify, reply, token)
})
/** --- Yandex --- */
fastify.get('/api/auth/oauth/yandex', async (_request, reply) => {
const clientId = process.env.YANDEX_CLIENT_ID
if (!clientId) return reply.code(503).send({ error: 'Yandex OAuth не настроен (нет YANDEX_* в env)' })
const redirectUri = `${serverPublic}/api/auth/oauth/yandex/callback`
const state = fastify.jwt.sign({ oauth: 'yandex' }, { expiresIn: '15m' })
const url = new URL('https://oauth.yandex.ru/authorize')
url.searchParams.set('response_type', 'code')
url.searchParams.set('client_id', clientId)
url.searchParams.set('redirect_uri', redirectUri)
url.searchParams.set('scope', 'login:email login:info')
url.searchParams.set('state', state)
return reply.redirect(url.toString())
})
fastify.get('/api/auth/oauth/yandex/callback', async (request, reply) => {
const query = request.query ?? {}
if (query.error) return oauthErrorRedirect(reply, String(query.error))
try {
const state = typeof query.state === 'string' ? query.state : ''
fastify.jwt.verify(state || '')
} catch {
return oauthErrorRedirect(reply, 'Недействительный state OAuth')
}
const code = typeof query.code === 'string' ? query.code.trim() : ''
if (!code) return oauthErrorRedirect(reply, 'Не получен код от Яндекс')
const clientId = process.env.YANDEX_CLIENT_ID
const clientSecret = process.env.YANDEX_CLIENT_SECRET
const redirectUri = `${serverPublic}/api/auth/oauth/yandex/callback`
const body = new URLSearchParams()
body.set('grant_type', 'authorization_code')
body.set('code', code)
body.set('client_id', clientId)
body.set('client_secret', clientSecret)
if (redirectUri) body.set('redirect_uri', redirectUri)
const tokenRes = await fetch('https://oauth.yandex.ru/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: body.toString(),
})
const tokenBody = await tokenRes.json()
if (!tokenRes.ok || !tokenBody.access_token) {
return oauthErrorRedirect(
reply,
tokenBody.error_description || tokenBody.error || 'Не удалось обменять код Yandex',
)
}
const yaToken = tokenBody.access_token
const infoRes = await fetch('https://login.yandex.ru/info', {
headers: { Authorization: `OAuth ${yaToken}` },
})
const info = await infoRes.json()
const yaUserId = String(info?.id || '')
if (!yaUserId) return oauthErrorRedirect(reply, 'Не удалось получить профиль Yandex')
const emailGuess =
(Array.isArray(info?.emails) && info.emails[0]) ||
info?.default_email ||
(info?.login ? `${info.login}@yandex.ru` : null)
const user = await findOrCreateUserFromOAuth({
provider: 'yandex',
providerUserId: yaUserId,
accessToken: yaToken,
suggestedEmail: emailGuess || null,
})
const dn = `${info.first_name ?? ''} ${info.last_name ?? ''}`.trim()
if (dn && !user.name) {
await prisma.user.update({ where: { id: user.id }, data: { name: dn } })
}
const token = await issueUserJwt(fastify, user.id, user.email)
return clientRedirect(fastify, reply, token)
})
}