From 9139a240936ad0bc9a39255bccd6697e28028fb8 Mon Sep 17 00:00:00 2001 From: "@kirill.komarov" Date: Thu, 30 Apr 2026 22:34:55 +0500 Subject: [PATCH] base commit --- README.md | 18 ++ client/src/app/App.tsx | 3 +- client/src/app/layout/AppHeader.tsx | 16 +- client/src/app/layout/MainLayout.tsx | 75 ++++- .../src/entities/order/api/admin-order-api.ts | 15 +- client/src/entities/order/api/order-api.ts | 33 ++- .../src/entities/product/api/product-api.ts | 2 + .../src/entities/product/api/reviews-api.ts | 54 ++++ client/src/entities/product/model/types.ts | 8 + .../src/entities/product/ui/ProductCard.tsx | 35 +++ client/src/entities/user/api/messages-api.ts | 24 ++ .../toggle-cart-icon/ui/ToggleCartIcon.tsx | 19 +- .../pages/admin-layout/ui/AdminLayoutPage.tsx | 27 +- .../pages/admin-orders/ui/AdminOrdersPage.tsx | 50 +++- client/src/pages/auth/index.ts | 1 + client/src/pages/auth/ui/AuthCallbackPage.tsx | 28 ++ client/src/pages/auth/ui/AuthPage.tsx | 29 +- client/src/pages/checkout/ui/CheckoutPage.tsx | 101 +++++-- client/src/pages/home/ui/HomePage.tsx | 90 +++++- client/src/pages/me/ui/MeLayoutPage.tsx | 27 +- .../src/pages/me/ui/sections/MessagesPage.tsx | 193 ++++++++++++- .../pages/me/ui/sections/OrderDetailPage.tsx | 258 ++++++++++++++++-- .../src/pages/me/ui/sections/OrdersPage.tsx | 3 +- client/src/pages/product/ui/ProductPage.tsx | 80 ++++++ client/src/shared/config/index.ts | 5 + client/src/shared/constants/order.ts | 31 ++- client/src/shared/lib/admin-token.ts | 13 + client/src/shared/lib/oauth-authorize-url.ts | 7 + client/src/shared/lib/order-status-labels.ts | 15 + client/src/shared/lib/reviews-count-ru.ts | 12 + client/src/vite-env.d.ts | 3 + client/src/widgets/reviews-block/index.ts | 1 + .../widgets/reviews-block/ui/ReviewsBlock.tsx | 119 ++++++++ server/.env.example | 17 +- .../migration.sql | 25 ++ .../migration.sql | 35 +++ server/prisma/schema.prisma | 41 ++- server/src/index.js | 2 + server/src/lib/order-status.js | 47 +++- server/src/lib/review-display.js | 13 + server/src/routes/api/_product-helpers.js | 8 +- server/src/routes/api/admin-orders.js | 24 +- server/src/routes/api/public-catalog.js | 83 +++++- server/src/routes/api/public-reviews.js | 42 ++- server/src/routes/auth.js | 200 ++++++++++++-- server/src/routes/oauth-social.js | 244 +++++++++++++++++ 46 files changed, 2023 insertions(+), 153 deletions(-) create mode 100644 client/src/entities/product/api/reviews-api.ts create mode 100644 client/src/entities/user/api/messages-api.ts create mode 100644 client/src/pages/auth/ui/AuthCallbackPage.tsx create mode 100644 client/src/shared/lib/oauth-authorize-url.ts create mode 100644 client/src/shared/lib/order-status-labels.ts create mode 100644 client/src/shared/lib/reviews-count-ru.ts create mode 100644 client/src/widgets/reviews-block/index.ts create mode 100644 client/src/widgets/reviews-block/ui/ReviewsBlock.tsx create mode 100644 server/prisma/migrations/20260430063434_order_delivery_type_fee/migration.sql create mode 100644 server/prisma/migrations/20260430170746_user_message_read_oauth_accounts/migration.sql create mode 100644 server/src/lib/review-display.js create mode 100644 server/src/routes/oauth-social.js 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)} ))} + + Способ получения + + {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() { )} + Быстрый вход + + + + + + или по 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() { )} - Адрес доставки + Способ получения - {addresses.length === 0 && ( - - У вас нет адресов доставки. Добавьте адрес в{' '} - - кабинете - - . - + {deliveryType === 'delivery' && ( + <> + + Адрес доставки + + + + {addresses.length === 0 && ( + + У вас нет адресов доставки. Добавьте адрес в{' '} + + кабинете + + . + + )} + + + Стоимость доставки: 500 ₽ за каждые 2 единицы (минимум 500 ₽). + {items.length > 0 && ( + <> + {' '} + В этом заказе: {totalQty} шт. → доставка {formatPriceRub(deliveryFeeCents)}. + + )} + + + )} + + {deliveryType === 'pickup' && ( + Самовывоз: адрес доставки не нужен. Мы свяжемся с вами для согласования. )} - Итого: {formatPriceRub(total)} + + + Товары: {formatPriceRub(itemsSubtotalCents)} + + {deliveryType === 'delivery' && ( + + Доставка: {formatPriceRub(deliveryFeeCents)} + + )} + Итого: {formatPriceRub(total)} + + + + {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} + /> + + + + )} + + + )} ) } 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)} + {order.status === 'PENDING_PAYMENT' && ( + <> + + Пока это заглушка. После нажатия заказ перейдёт в статус «Проверка оплаты». + + + + )} + {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' + ? 'Когда забрали посылку у курьера или на пункте выдачи — подтвердите получение.' + : 'Когда забрали заказ самовывозом — подтвердите получение.'} + + + + ) : null} + + {order.status === 'DONE' && eligibilityQuery.isSuccess && eligibilityQuery.data.canReview && ( + + + Отзывы + + + Поделитесь впечатлением о товарах. Отзывы появляются после модерации. + + + {eligibilityQuery.data.items.map((row) => ( + + {row.title} + + + ))} + + + )} + Чат по заказу @@ -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)} + + )} + + + + + + ) } 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) + }) +}