diff --git a/README.md b/README.md
index 33aba49..365af45 100644
--- a/README.md
+++ b/README.md
@@ -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 (кратко)
Публичные:
diff --git a/client/src/app/App.tsx b/client/src/app/App.tsx
index 7cb4180..d684547 100644
--- a/client/src/app/App.tsx
+++ b/client/src/app/App.tsx
@@ -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() {
} />
} />
} />
+ } />
} />
} />
} />
diff --git a/client/src/app/layout/AppHeader.tsx b/client/src/app/layout/AppHeader.tsx
index 02a30f5..637d6a3 100644
--- a/client/src/app/layout/AppHeader.tsx
+++ b/client/src/app/layout/AppHeader.tsx
@@ -62,11 +62,11 @@ function ThemeControlsDesktop(props: {
'& .MuiInputLabel-root.MuiInputLabel-shrink': { color: 'rgba(255,255,255,0.92)' },
}}
>
- Тема
+ Схема
- Режим
+ Тема
- Тема
-
+ Схема
+
Крафт
Лес
Океан
@@ -149,8 +149,8 @@ function ThemeControlsMobile(props: {
- Режим
-
+ Тема
+
Авто (система)
Светлая
Тёмная
diff --git a/client/src/app/layout/MainLayout.tsx b/client/src/app/layout/MainLayout.tsx
index 6f0e592..e2fdfe7 100644
--- a/client/src/app/layout/MainLayout.tsx
+++ b/client/src/app/layout/MainLayout.tsx
@@ -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 (
@@ -12,17 +20,76 @@ export function MainLayout({ children }: PropsWithChildren) {
{children}
+
- Изделия ручной работы · доставка по договорённости
+
+
+
+
+ Магазин
+
+
+
+ Каталог
+
+
+ Изделия ручной работы: вещь с характером и вниманием к деталям.
+
+
+ Как заказать: добавьте позиции в корзину и оформите доставку или самовывоз на чек-ауте.
+
+
+
+
+
+ Покупателям
+
+
+
+ Личный кабинет
+
+
+ Доставка и самовывоз: уточняются при оформлении заказа; по вопросам — контакты ниже.
+
+
+
+
+
+ Контакты
+
+
+
+ Email:{' '}
+
+ {STORE_EMAIL}
+
+
+
+ Телефон:{' '}
+
+ {STORE_PHONE}
+
+
+
+ {STORE_SOCIAL_NOTE}
+
+
+
+
+
+
+ © {year} {STORE_NAME}. Сделано для демонстрации возможностей витрины.
+
+
)
diff --git a/client/src/entities/order/api/admin-order-api.ts b/client/src/entities/order/api/admin-order-api.ts
index 23d0721..fe61423 100644
--- a/client/src/entities/order/api/admin-order-api.ts
+++ b/client/src/entities/order/api/admin-order-api.ts
@@ -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 {
const { data } = await apiClient.get('admin/orders', {
params,
diff --git a/client/src/entities/order/api/order-api.ts b/client/src/entities/order/api/order-api.ts
index 3d02f52..0e3a431 100644
--- a/client/src/entities/order/api/order-api.ts
+++ b/client/src/entities/order/api/order-api.ts
@@ -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
await apiClient.post(`me/orders/${id}/messages`, { text })
}
-export async function payOrderStub(id: string): Promise {
- 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
}
diff --git a/client/src/entities/product/api/product-api.ts b/client/src/entities/product/api/product-api.ts
index 7187f31..03671cf 100644
--- a/client/src/entities/product/api/product-api.ts
+++ b/client/src/entities/product/api/product-api.ts
@@ -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,
diff --git a/client/src/entities/product/api/reviews-api.ts b/client/src/entities/product/api/reviews-api.ts
new file mode 100644
index 0000000..70864dd
--- /dev/null
+++ b/client/src/entities/product/api/reviews-api.ts
@@ -0,0 +1,54 @@
+import { apiClient } from '@/shared/api/client'
+
+export async function postProductReview(
+ productId: string,
+ body: { rating: number; text?: string | null },
+): Promise {
+ 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 {
+ const { data } = await apiClient.get('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 {
+ const { data } = await apiClient.get(`products/${productId}/reviews`, {
+ params: { page: params?.page, pageSize: params?.pageSize },
+ })
+ return data
+}
diff --git a/client/src/entities/product/model/types.ts b/client/src/entities/product/model/types.ts
index fa22da1..4042dac 100644
--- a/client/src/entities/product/model/types.ts
+++ b/client/src/entities/product/model/types.ts
@@ -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
}
diff --git a/client/src/entities/product/ui/ProductCard.tsx b/client/src/entities/product/ui/ProductCard.tsx
index 2ecfbca..b4777f3 100644
--- a/client/src/entities/product/ui/ProductCard.tsx
+++ b/client/src/entities/product/ui/ProductCard.tsx
@@ -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) {
{product.category && }
+ {product.inStock && product.quantity === 0 && }
{!product.inStock && (
{formatPriceRub(product.priceCents)}
+ {product.reviewsSummary && product.reviewsSummary.approvedReviewCount > 0 && (
+
+
+
+
+ {reviewsCountRu(product.reviewsSummary.approvedReviewCount)}
+
+
+ {product.reviewsSummary.latestApprovedText ? (
+
+ «{product.reviewsSummary.latestApprovedText}»
+
+ ) : null}
+
+ )}
{actions}
diff --git a/client/src/entities/user/api/messages-api.ts b/client/src/entities/user/api/messages-api.ts
new file mode 100644
index 0000000..8f24233
--- /dev/null
+++ b/client/src/entities/user/api/messages-api.ts
@@ -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 {
+ 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
+}
diff --git a/client/src/features/cart/toggle-cart-icon/ui/ToggleCartIcon.tsx b/client/src/features/cart/toggle-cart-icon/ui/ToggleCartIcon.tsx
index 4eb2ed8..e3cfbf2 100644
--- a/client/src/features/cart/toggle-cart-icon/ui/ToggleCartIcon.tsx
+++ b/client/src/features/cart/toggle-cart-icon/ui/ToggleCartIcon.tsx
@@ -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 (
diff --git a/client/src/pages/admin-layout/ui/AdminLayoutPage.tsx b/client/src/pages/admin-layout/ui/AdminLayoutPage.tsx
index 7ce779b..111dfe2 100644
--- a/client/src/pages/admin-layout/ui/AdminLayoutPage.tsx
+++ b/client/src/pages/admin-layout/ui/AdminLayoutPage.tsx
@@ -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)
}}
>
- {i.icon}
+
+ {i.to === '/admin/orders' ? (
+
+ {i.icon}
+
+ ) : (
+ i.icon
+ )}
+
))}
diff --git a/client/src/pages/admin-orders/ui/AdminOrdersPage.tsx b/client/src/pages/admin-orders/ui/AdminOrdersPage.tsx
index 135ea5c..e80f468 100644
--- a/client/src/pages/admin-orders/ui/AdminOrdersPage.tsx
+++ b/client/src/pages/admin-orders/ui/AdminOrdersPage.tsx
@@ -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(() => getAdminToken())
const [q, setQ] = useState('')
const [status, setStatus] = useState('')
+ const [deliveryType, setDeliveryType] = useState<'delivery' | 'pickup' | ''>('')
const [dialogOpen, setDialogOpen] = useState(false)
const [selectedId, setSelectedId] = useState(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 (
@@ -153,13 +160,31 @@ export function AdminOrdersPage() {
Все
- {Object.keys(ORDER_STATUS_TRANSITIONS).map((s) => (
+ {ORDER_STATUSES.map((s) => (
- {s}
+ {orderStatusLabelRu(s)}
))}
+
+ Способ получения
+ {
+ const v = String(e.target.value)
+ if (v === '' || v === 'delivery' || v === 'pickup') setDeliveryType(v)
+ }}
+ >
+
+ Все
+
+ Доставка
+ Самовывоз
+
+
{ordersQuery.isError && Не удалось загрузить заказы. }
@@ -180,7 +205,7 @@ export function AdminOrdersPage() {
{o.id.slice(-8)}
{o.user.email}
- {o.status}
+ {orderStatusLabelRu(o.status)}
{formatPriceRub(o.totalCents)}
{o.itemsCount}
@@ -210,7 +235,8 @@ export function AdminOrdersPage() {
{detail && (
- #{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)}
@@ -232,7 +258,7 @@ export function AdminOrdersPage() {
{nextStatuses.map((s) => (
- {s}
+ {orderStatusLabelRu(s)}
))}
diff --git a/client/src/pages/auth/index.ts b/client/src/pages/auth/index.ts
index 488bd5c..98a437c 100644
--- a/client/src/pages/auth/index.ts
+++ b/client/src/pages/auth/index.ts
@@ -1 +1,2 @@
+export { AuthCallbackPage } from './ui/AuthCallbackPage'
export { AuthPage } from './ui/AuthPage'
diff --git a/client/src/pages/auth/ui/AuthCallbackPage.tsx b/client/src/pages/auth/ui/AuthCallbackPage.tsx
new file mode 100644
index 0000000..becefd3
--- /dev/null
+++ b/client/src/pages/auth/ui/AuthCallbackPage.tsx
@@ -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 (
+
+
+ Завершение входа…
+
+ )
+}
diff --git a/client/src/pages/auth/ui/AuthPage.tsx b/client/src/pages/auth/ui/AuthPage.tsx
index 7a6e126..ae85e20 100644
--- a/client/src/pages/auth/ui/AuthPage.tsx
+++ b/client/src/pages/auth/ui/AuthPage.tsx
@@ -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(null)
+ const [oauthError, setOauthError] = useState(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}
)}
+ {oauthError && (
+ setOauthError(null)}>
+ {oauthError}
+
+ )}
{errMsg && (
{errMsg}
@@ -101,6 +116,18 @@ export function AuthPage() {
)}
+ Быстрый вход
+
+
+ Войти через VK
+
+
+ Войти через Яндекс
+
+
+
+ или по email
+
Вариант 1: Email + код
diff --git a/client/src/pages/checkout/ui/CheckoutPage.tsx b/client/src/pages/checkout/ui/CheckoutPage.tsx
index 3c435ad..b22f2f7 100644
--- a/client/src/pages/checkout/ui/CheckoutPage.tsx
+++ b/client/src/pages/checkout/ui/CheckoutPage.tsx
@@ -22,6 +22,7 @@ export function CheckoutPage() {
const user = useUnit($user)
const qc = useQueryClient()
const navigate = useNavigate()
+ const [deliveryType, setDeliveryType] = useState<'delivery' | 'pickup'>('delivery')
const [addressId, setAddressId] = useState('')
const [comment, setComment] = useState('')
@@ -41,7 +42,12 @@ export function CheckoutPage() {
const selectedAddressId = addressId || defaultAddressId
const createMut = useMutation({
- mutationFn: () => createOrder({ addressId: selectedAddressId, comment: comment.trim() || null }),
+ mutationFn: () =>
+ createOrder({
+ deliveryType,
+ addressId: deliveryType === 'delivery' ? selectedAddressId : null,
+ comment: comment.trim() || null,
+ }),
onSuccess: async (res) => {
await qc.invalidateQueries({ queryKey: ['me', 'cart'] })
navigate(`/me/orders/${res.orderId}`, { replace: true })
@@ -61,7 +67,11 @@ export function CheckoutPage() {
}
const items = cartQuery.data?.items ?? []
- const total = items.reduce((s, x) => s + x.product.priceCents * x.qty, 0)
+ const itemsSubtotalCents = items.reduce((s, x) => s + x.product.priceCents * x.qty, 0)
+ const totalQty = items.reduce((s, x) => s + x.qty, 0)
+ const deliveryFeeCents =
+ deliveryType === 'delivery' && items.length > 0 ? 50000 * Math.max(1, Math.ceil(totalQty / 2)) : 0
+ const total = itemsSubtotalCents + deliveryFeeCents
const addresses = addressesQuery.data?.items ?? []
const hasOverLimit = items.some((x) => x.qty > (x.product.inStock ? x.product.quantity : 1))
const hasMadeToOrder = items.some((x) => !x.product.inStock)
@@ -117,29 +127,63 @@ export function CheckoutPage() {
)}
- Адрес доставки
+ Способ получения
setAddressId(String(e.target.value))}
+ labelId="delivery-type-label"
+ label="Способ получения"
+ value={deliveryType}
+ onChange={(e) => {
+ const v = String(e.target.value)
+ if (v === 'delivery' || v === 'pickup') setDeliveryType(v)
+ }}
>
- {addresses.map((a) => (
-
- {(a.label?.trim() ? `${a.label}: ` : '') + a.addressLine}
-
- ))}
+ Доставка
+ Самовывоз
- {addresses.length === 0 && (
-
- У вас нет адресов доставки. Добавьте адрес в{' '}
-
- кабинете
-
- .
-
+ {deliveryType === 'delivery' && (
+ <>
+
+ Адрес доставки
+ setAddressId(String(e.target.value))}
+ >
+ {addresses.map((a) => (
+
+ {(a.label?.trim() ? `${a.label}: ` : '') + a.addressLine}
+
+ ))}
+
+
+
+ {addresses.length === 0 && (
+
+ У вас нет адресов доставки. Добавьте адрес в{' '}
+
+ кабинете
+
+ .
+
+ )}
+
+
+ Стоимость доставки: 500 ₽ за каждые 2 единицы (минимум 500 ₽).
+ {items.length > 0 && (
+ <>
+ {' '}
+ В этом заказе: {totalQty} шт. → доставка {formatPriceRub(deliveryFeeCents)}.
+ >
+ )}
+
+ >
+ )}
+
+ {deliveryType === 'pickup' && (
+ Самовывоз: адрес доставки не нужен. Мы свяжемся с вами для согласования.
)}
- Итого: {formatPriceRub(total)}
+
+
+ Товары: {formatPriceRub(itemsSubtotalCents)}
+
+ {deliveryType === 'delivery' && (
+
+ Доставка: {formatPriceRub(deliveryFeeCents)}
+
+ )}
+ Итого: {formatPriceRub(total)}
+
createMut.mutate()}
>
diff --git a/client/src/pages/home/ui/HomePage.tsx b/client/src/pages/home/ui/HomePage.tsx
index 0a0b7c5..7b45cd5 100644
--- a/client/src/pages/home/ui/HomePage.tsx
+++ b/client/src/pages/home/ui/HomePage.tsx
@@ -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('')
+ 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() {
/>
+
+
+ Наличие
+
+ Быстрый фильтр по наличию
+
+
+
+ {
+ 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' },
+ },
+ }}
+ >
+ Все
+ В наличии
+ Под заказ
+
+
+
{
setCategorySlug('')
+ setAvailability('all')
setQInput('')
setSort('')
setPriceMinRub('')
@@ -312,22 +363,37 @@ export function HomePage() {
{products.map((p) => (
- } />
+
+ }
+ />
))}
-
- setPage(v)}
- color="primary"
- shape="rounded"
- showFirstButton
- showLastButton
- />
-
+ {totalPages > 1 && (
+
+ setPage(v)}
+ color="primary"
+ shape="rounded"
+ showFirstButton
+ showLastButton
+ />
+
+ )}
+
+
+
+
>
)}
diff --git a/client/src/pages/me/ui/MeLayoutPage.tsx b/client/src/pages/me/ui/MeLayoutPage.tsx
index 46c583e..5c6515e 100644
--- a/client/src/pages/me/ui/MeLayoutPage.tsx
+++ b/client/src/pages/me/ui/MeLayoutPage.tsx
@@ -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: },
@@ -81,7 +94,19 @@ export function MeLayoutPage() {
setMobileOpen(false)
}}
>
- {i.icon}
+
+ {i.to === '/me/messages' ? (
+ 99 ? '99+' : unreadMessages}
+ invisible={unreadMessages === 0}
+ >
+ {i.icon}
+
+ ) : (
+ i.icon
+ )}
+
))}
diff --git a/client/src/pages/me/ui/sections/MessagesPage.tsx b/client/src/pages/me/ui/sections/MessagesPage.tsx
index 5e2a69b..a61fe5b 100644
--- a/client/src/pages/me/ui/sections/MessagesPage.tsx
+++ b/client/src/pages/me/ui/sections/MessagesPage.tsx
@@ -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(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 (
Сообщения
- Скоро здесь появятся сообщения и уведомления.
+
+
+ Переписка по всем заказам. Последнее сообщение в каждом заказе — в списке слева.
+
+
+ {listQuery.isError && Не удалось загрузить переписки. }
+ {listQuery.isSuccess && conversationsList.length === 0 && (
+
+ Пока нет сообщений в заказах. Их отправит администратор — или напишите сами на странице заказа.
+
+ )}
+
+ {conversationsList.length > 0 && (
+
+
+
+ {conversationsList.map((c) => (
+ 0 ? (
+
+ ) : null
+ }
+ >
+ setSelectedId(c.orderId)}>
+
+
+ №{c.orderId.slice(-6)}
+
+
+ · {orderStatusLabelRu(c.status)}
+
+
+ }
+ secondaryTypographyProps={{
+ sx: {
+ mt: 0.5,
+ overflow: 'hidden',
+ display: '-webkit-box',
+ WebkitLineClamp: 2,
+ WebkitBoxOrient: 'vertical',
+ },
+ }}
+ secondary={c.preview}
+ />
+
+
+ ))}
+
+
+
+
+ {!activeThreadId && Выберите заказ. }
+ {activeThreadId && orderQuery.isLoading && Загрузка чата… }
+ {activeThreadId && orderQuery.isError && Не удалось загрузить заказ. }
+ {order && (
+ <>
+
+
+ Чат заказа №{order.id.slice(-6)}{' '}
+
+ ({orderStatusLabelRu(order.status)})
+
+
+
+ Открыть заказ
+
+
+
+ {order.messages.map((m) => (
+
+
+ {m.authorType === 'admin' ? 'Админ' : 'Вы'} · {new Date(m.createdAt).toLocaleString()}
+
+ {m.text}
+
+ ))}
+ {order.messages.length === 0 && Нет сообщений. }
+
+
+ setText(e.target.value)}
+ fullWidth
+ multiline
+ minRows={2}
+ />
+ msgMut.mutate()}
+ disabled={msgMut.isPending || !text.trim()}
+ >
+ Отправить
+
+
+ >
+ )}
+
+
+ )}
)
}
diff --git a/client/src/pages/me/ui/sections/OrderDetailPage.tsx b/client/src/pages/me/ui/sections/OrderDetailPage.tsx
index d912713..c58433a 100644
--- a/client/src/pages/me/ui/sections/OrderDetailPage.tsx
+++ b/client/src/pages/me/ui/sections/OrderDetailPage.tsx
@@ -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(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() {
Заказ #{order.id.slice(-6)}
- Статус: {order.status}
+ Статус: {orderStatusLabelRu(order.status)}
К списку
@@ -89,27 +170,49 @@ export function OrderDetailPage() {
))}
- Итого: {formatPriceRub(order.totalCents)}
+
+
+ Товары: {formatPriceRub(order.itemsSubtotalCents)}
+
+ {order.deliveryType === 'delivery' && (
+
+ Доставка: {formatPriceRub(order.deliveryFeeCents)}
+
+ )}
+ Итого: {formatPriceRub(order.totalCents)}
+
- Доставка
+ Получение
- {address ? (
+
+ Способ: {order.deliveryType === 'pickup' ? 'Самовывоз' : 'Доставка'}
+
+ {order.deliveryType === 'delivery' && (
<>
- {address.addressLine}
-
- Получатель: {address.recipientName} · {address.recipientPhone}
-
- {address.comment && (
-
- Комментарий: {address.comment}
-
+ {address ? (
+ <>
+ {address.addressLine}
+
+ Получатель: {address.recipientName} · {address.recipientPhone}
+
+ {address.comment && (
+
+ Комментарий: {address.comment}
+
+ )}
+ >
+ ) : (
+ Адрес не распознан.
)}
>
- ) : (
- Адрес не распознан.
+ )}
+ {order.deliveryType === 'pickup' && (
+
+ Адрес доставки не требуется. Мы свяжемся для согласования самовывоза.
+
)}
{order.comment && (
@@ -122,14 +225,81 @@ export function OrderDetailPage() {
Оплата
-
- Пока это заглушка. Позже подключим реальную оплату.
-
- payMut.mutate()} disabled={payMut.isPending}>
- Оплатить (заглушка)
-
+ {order.status === 'PENDING_PAYMENT' && (
+ <>
+
+ Пока это заглушка. После нажатия заказ перейдёт в статус «Проверка оплаты».
+
+ payMut.mutate()} disabled={payMut.isPending}>
+ Оплатить (заглушка)
+
+ >
+ )}
+ {order.status === 'PAYMENT_VERIFICATION' && (
+
+ Оплата отправлена на проверку. Мы проверим поступление и обновим статус.
+
+ )}
+ {!['PENDING_PAYMENT', 'PAYMENT_VERIFICATION'].includes(order.status) && (
+
+ На этом этапе действий по оплате в этом блоке не требуется.
+
+ )}
+ {(order.deliveryType === 'delivery' && order.status === 'SHIPPED') ||
+ (order.deliveryType === 'pickup' && order.status === 'READY_FOR_PICKUP') ? (
+
+
+ Получение заказа
+
+
+ {order.deliveryType === 'delivery'
+ ? 'Когда забрали посылку у курьера или на пункте выдачи — подтвердите получение.'
+ : 'Когда забрали заказ самовывозом — подтвердите получение.'}
+
+ confirmMut.mutate()}
+ disabled={confirmMut.isPending}
+ >
+ Подтвердить получение
+
+
+ ) : null}
+
+ {order.status === 'DONE' && eligibilityQuery.isSuccess && eligibilityQuery.data.canReview && (
+
+
+ Отзывы
+
+
+ Поделитесь впечатлением о товарах. Отзывы появляются после модерации.
+
+
+ {eligibilityQuery.data.items.map((row) => (
+
+ {row.title}
+ setReviewTarget({ productId: row.productId, title: row.title })}
+ >
+ {row.hasReview ? 'Отзыв отправлен' : 'Оставить отзыв'}
+
+
+ ))}
+
+
+ )}
+
Чат по заказу
@@ -175,6 +345,48 @@ export function OrderDetailPage() {
+
+ !reviewMut.isPending && setReviewTarget(null)}
+ fullWidth
+ maxWidth="sm"
+ >
+ Отзыв: {reviewTarget?.title}
+
+
+ Оценка
+
+ {
+ if (v !== null) setReviewRating(v)
+ }}
+ />
+ setReviewText(e.target.value)}
+ fullWidth
+ multiline
+ minRows={3}
+ />
+ {reviewMut.isError && (
+
+ {reviewSubmitErrorMessage(reviewMut.error)}
+
+ )}
+
+
+ setReviewTarget(null)} disabled={reviewMut.isPending}>
+ Отмена
+
+ reviewMut.mutate()}>
+ Отправить
+
+
+
)
}
diff --git a/client/src/pages/me/ui/sections/OrdersPage.tsx b/client/src/pages/me/ui/sections/OrdersPage.tsx
index 7f7dc67..6c8e1dc 100644
--- a/client/src/pages/me/ui/sections/OrdersPage.tsx
+++ b/client/src/pages/me/ui/sections/OrdersPage.tsx
@@ -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() {
Заказ #{o.id.slice(-6)}
- Статус: {o.status} · {o.itemsCount} поз.
+ Статус: {orderStatusLabelRu(o.status)} · {o.itemsCount} поз.
{formatPriceRub(o.totalCents)}
diff --git a/client/src/pages/product/ui/ProductPage.tsx b/client/src/pages/product/ui/ProductPage.tsx
index a73e361..e174d29 100644
--- a/client/src/pages/product/ui/ProductPage.tsx
+++ b/client/src/pages/product/ui/ProductPage.tsx
@@ -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() {
) : (
Описание появится позже.
)}
+
+
+
+
+ Отзывы
+
+ {p.reviewsSummary && p.reviewsSummary.approvedReviewCount > 0 && (
+
+ }
+ emptyIcon={ }
+ />
+
+ {reviewsCountRu(p.reviewsSummary.approvedReviewCount)}
+
+
+ )}
+
+ {reviewsQuery.isLoading && Загрузка отзывов… }
+ {reviewsQuery.isError && Не удалось загрузить отзывы. }
+ {reviewsQuery.data && reviewsQuery.data.total === 0 && (
+ Пока нет опубликованных отзывов на этот товар.
+ )}
+ {reviewsQuery.data && reviewsQuery.data.items.length > 0 && (
+
+ {reviewsQuery.data.items.map((rv) => {
+ const body = typeof rv.text === 'string' && rv.text.trim() ? rv.text.trim() : null
+ return (
+
+
+
+ {rv.authorDisplay}
+
+ {new Date(rv.createdAt).toLocaleString('ru-RU')}
+
+
+ }
+ emptyIcon={ }
+ />
+ {body ? (
+
+ {body}
+
+ ) : (
+
+ Без текстового комментария.
+
+ )}
+
+
+ )
+ })}
+ {reviewsQuery.data.total > reviewsQuery.data.items.length && (
+
+ Всего {reviewsCountRu(reviewsQuery.data.total)} — ниже показаны последние{' '}
+ {reviewsQuery.data.items.length}.
+
+ )}
+
+ )}
setViewerOpen(false)}>
diff --git a/client/src/shared/config/index.ts b/client/src/shared/config/index.ts
index a85ddb0..6d4b210 100644
--- a/client/src/shared/config/index.ts
+++ b/client/src/shared/config/index.ts
@@ -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 ?? 'Соцсети: укажите ссылки при публикации'
diff --git a/client/src/shared/constants/order.ts b/client/src/shared/constants/order.ts
index d8fc61e..633fdae 100644
--- a/client/src/shared/constants/order.ts
+++ b/client/src/shared/constants/order.ts
@@ -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 = {
- 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)
}
diff --git a/client/src/shared/lib/admin-token.ts b/client/src/shared/lib/admin-token.ts
index d34b4e4..e0161b2 100644
--- a/client/src/shared/lib/admin-token.ts
+++ b/client/src/shared/lib/admin-token.ts
@@ -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()
}
diff --git a/client/src/shared/lib/oauth-authorize-url.ts b/client/src/shared/lib/oauth-authorize-url.ts
new file mode 100644
index 0000000..942d801
--- /dev/null
+++ b/client/src/shared/lib/oauth-authorize-url.ts
@@ -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}`
+}
diff --git a/client/src/shared/lib/order-status-labels.ts b/client/src/shared/lib/order-status-labels.ts
new file mode 100644
index 0000000..564a9e6
--- /dev/null
+++ b/client/src/shared/lib/order-status-labels.ts
@@ -0,0 +1,15 @@
+/** Человекочитаемые подписи к кодам статуса заказа */
+export function orderStatusLabelRu(code: string): string {
+ const map: Record = {
+ DRAFT: 'Черновик',
+ PENDING_PAYMENT: 'Ожидает оплаты',
+ PAYMENT_VERIFICATION: 'Проверка оплаты',
+ PAID: 'Оплачен',
+ IN_PROGRESS: 'В работе',
+ SHIPPED: 'Отправлен',
+ READY_FOR_PICKUP: 'Готово к получению',
+ DONE: 'Завершён',
+ CANCELLED: 'Отменён',
+ }
+ return map[code] ?? code
+}
diff --git a/client/src/shared/lib/reviews-count-ru.ts b/client/src/shared/lib/reviews-count-ru.ts
new file mode 100644
index 0000000..8c44ff6
--- /dev/null
+++ b/client/src/shared/lib/reviews-count-ru.ts
@@ -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}`
+}
diff --git a/client/src/vite-env.d.ts b/client/src/vite-env.d.ts
index 0962348..45624e3 100644
--- a/client/src/vite-env.d.ts
+++ b/client/src/vite-env.d.ts
@@ -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 {
diff --git a/client/src/widgets/reviews-block/index.ts b/client/src/widgets/reviews-block/index.ts
new file mode 100644
index 0000000..05fcf50
--- /dev/null
+++ b/client/src/widgets/reviews-block/index.ts
@@ -0,0 +1 @@
+export { ReviewsBlock } from './ui/ReviewsBlock'
diff --git a/client/src/widgets/reviews-block/ui/ReviewsBlock.tsx b/client/src/widgets/reviews-block/ui/ReviewsBlock.tsx
new file mode 100644
index 0000000..0696294
--- /dev/null
+++ b/client/src/widgets/reviews-block/ui/ReviewsBlock.tsx
@@ -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 (
+
+
+ Отзывы
+
+ Последние одобренные отзывы о товарах
+
+
+
+ {q.isLoading && (
+
+ {[1, 2, 3].map((i) => (
+
+ ))}
+
+ )}
+ {q.isError && Не удалось загрузить отзывы. }
+ {!q.isLoading && !q.isError && q.data && items.length === 0 && (
+ Пока нет опубликованных отзывов о товарах.
+ )}
+ {items.length > 0 && (
+
+ {items.map((r, i) => {
+ const zebra = i % 2 === 0
+ const text = typeof r.text === 'string' && r.text.trim() ? r.text.trim() : 'Без комментария'
+ return (
+
+
+
+
+ {initials(r.authorDisplay)}
+
+
+ {r.authorDisplay}
+
+ }
+ emptyIcon={ }
+ />
+
+ {formatReviewDate(r.createdAt)}
+
+
+
+ {r.productTitle}
+
+
+
+
+
+ {text}
+
+
+
+ )
+ })}
+
+ )}
+
+ )
+}
diff --git a/server/.env.example b/server/.env.example
index 8938dd9..03348c7 100644
--- a/server/.env.example
+++ b/server/.env.example
@@ -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=
diff --git a/server/prisma/migrations/20260430063434_order_delivery_type_fee/migration.sql b/server/prisma/migrations/20260430063434_order_delivery_type_fee/migration.sql
new file mode 100644
index 0000000..1feea50
--- /dev/null
+++ b/server/prisma/migrations/20260430063434_order_delivery_type_fee/migration.sql
@@ -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;
diff --git a/server/prisma/migrations/20260430170746_user_message_read_oauth_accounts/migration.sql b/server/prisma/migrations/20260430170746_user_message_read_oauth_accounts/migration.sql
new file mode 100644
index 0000000..90ec5c3
--- /dev/null
+++ b/server/prisma/migrations/20260430170746_user_message_read_oauth_accounts/migration.sql
@@ -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");
diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma
index a5214f7..0bb0e8b 100644
--- a/server/prisma/schema.prisma
+++ b/server/prisma/schema.prisma
@@ -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
diff --git a/server/src/index.js b/server/src/index.js
index d50980c..55b80fa 100644
--- a/server/src/index.js
+++ b/server/src/index.js
@@ -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 }))
diff --git a/server/src/lib/order-status.js b/server/src/lib/order-status.js
index 2692efb..c510753 100644
--- a/server/src/lib/order-status.js
+++ b/server/src/lib/order-status.js
@@ -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)
}
-
diff --git a/server/src/lib/review-display.js b/server/src/lib/review-display.js
new file mode 100644
index 0000000..25e059e
--- /dev/null
+++ b/server/src/lib/review-display.js
@@ -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}`
+}
diff --git a/server/src/routes/api/_product-helpers.js b/server/src/routes/api/_product-helpers.js
index 83fb734..1e4c81f 100644
--- a/server/src/routes/api/_product-helpers.js
+++ b/server/src/routes/api/_product-helpers.js
@@ -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
}
diff --git a/server/src/routes/api/admin-orders.js b/server/src/routes/api/admin-orders.js
index d975117..da7224e 100644
--- a/server/src/routes/api/admin-orders.js
+++ b/server/src/routes/api/admin-orders.js
@@ -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}` })
}
diff --git a/server/src/routes/api/public-catalog.js b/server/src/routes/api/public-catalog.js
index 1208c78..57cdc98 100644
--- a/server/src/routes/api/public-catalog.js
+++ b/server/src/routes/api/public-catalog.js
@@ -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)
})
}
diff --git a/server/src/routes/api/public-reviews.js b/server/src/routes/api/public-reviews.js
index c6b3414..0fa9948 100644
--- a/server/src/routes/api/public-reviews.js
+++ b/server/src/routes/api/public-reviews.js
@@ -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 }
})
diff --git a/server/src/routes/auth.js b/server/src/routes/auth.js
index 69f118a..db69fe9 100644
--- a/server/src/routes/auth.js
+++ b/server/src/routes/auth.js
@@ -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 } })
- if (!address) return reply.code(404).send({ error: 'Адрес не найден' })
+ 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,17 +495,26 @@ 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({
- id: address.id,
- label: address.label,
- recipientName: address.recipientName,
- recipientPhone: address.recipientPhone,
- addressLine: address.addressLine,
- comment: address.comment,
- lat: address.lat,
- lng: address.lng,
- })
+ 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,
+ recipientPhone: address.recipientPhone,
+ addressLine: address.addressLine,
+ comment: address.comment,
+ lat: address.lat,
+ lng: address.lng,
+ })
let created
try {
@@ -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' }
},
)
}
diff --git a/server/src/routes/oauth-social.js b/server/src/routes/oauth-social.js
new file mode 100644
index 0000000..2f85a59
--- /dev/null
+++ b/server/src/routes/oauth-social.js
@@ -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)
+ })
+}