test commit

This commit is contained in:
Kirill
2026-05-21 14:22:03 +05:00
parent 058fa26e12
commit 47124a01a7
21 changed files with 535 additions and 527 deletions
-2
View File
@@ -2,13 +2,11 @@ import { lazy, Suspense } from 'react'
import { Route, Routes } from 'react-router-dom'
import { MainLayout } from '@/app/layout/MainLayout'
import { AboutPage } from '@/pages/about'
// import { AdminLayoutPage } from '@/pages/admin-layout'
import { AuthCallbackPage, AuthPage } from '@/pages/auth'
import { CartPage } from '@/pages/cart'
import { CheckoutPage } from '@/pages/checkout'
import { HomePage } from '@/pages/home'
import { InfoPage } from '@/pages/info'
// import { MeLayoutPage } from '@/pages/me'
import { NotFoundPage } from '@/pages/not-found'
import { PrivacyPolicyPage } from '@/pages/privacy-policy'
import { ProductPage } from '@/pages/product'
+3
View File
@@ -0,0 +1,3 @@
export type { CartItem } from './model/types'
export { fetchMyCart, addToCart, setCartQty, removeCartItem } from './api/cart-api'
export type { CartResponse } from './api/cart-api'
@@ -0,0 +1,7 @@
export {
fetchUserNotificationSettings,
updateUserNotificationSettings,
fetchAdminNotificationSettings,
updateAdminNotificationSettings,
} from './api/notifications-api'
export type { UserNotificationSettings, AdminNotificationSettings } from './api/notifications-api'
+9
View File
@@ -0,0 +1,9 @@
export {
fetchMyOrders,
createOrder,
confirmOrderReceived,
fetchMyOrder,
fetchOrderReviewEligibility,
} from './api/order-api'
export { createOrderPayment, getOrderPaymentStatus, postOrderMessage } from './api/order-api'
export type { OrderListResponse, OrderDetailResponse } from './api/order-api'
+2
View File
@@ -0,0 +1,2 @@
export { fetchPublicProducts, fetchPublicProduct, fetchCategories } from './api/product-api'
export type { PublicProductsResponse } from './api/product-api'
+12
View File
@@ -0,0 +1,12 @@
export {
postProductReview,
uploadReviewImage,
fetchLatestApprovedReviews,
fetchPublicProductReviews,
} from './api/reviews-api'
export type {
PublicReviewFeedItem,
PublicReviewsLatestResponse,
PublicProductReviewItem,
PublicProductReviewsResponse,
} from './api/reviews-api'
+10
View File
@@ -0,0 +1,10 @@
export type { AdminUser, ShippingAddress } from './model/types'
export { fetchAdminUsers, createAdminUser, updateAdminUser, deleteAdminUser } from './api/user-api'
export type { AdminUsersListResponse } from './api/user-api'
export {
fetchMyAddresses,
createMyAddress,
updateMyAddress,
deleteMyAddress,
setMyAddressDefault,
} from './api/address-api'
@@ -60,7 +60,7 @@ export function AdminLayoutPage() {
{ to: '/admin/orders', label: 'Заказы', icon: <ListOrdered /> },
{ to: '/admin/reviews', label: 'Отзывы', icon: <MessageSquare /> },
{ to: '/admin/users', label: 'Пользователи', icon: <Users /> },
{ to: '/admin/notifications', label: 'Оповещения', icon: <Bell /> },
{ to: '/admin/notifications', label: 'Уведомления', icon: <Bell /> },
],
[],
)
@@ -47,10 +47,10 @@ export function AdminNotificationsPage() {
return (
<Box>
<Typography variant="h4" gutterBottom>
Оповещения
Уведомления
</Typography>
<Typography color="text.secondary" sx={{ mb: 3 }}>
Настройка оповещений администратора.
Настройка уведомлений администратора.
</Typography>
{error && (
+1 -1
View File
@@ -57,7 +57,7 @@ export function MeLayoutPage() {
{ to: '/me/messages', label: 'Сообщения', icon: <MessageCircle /> },
{ to: '/me/settings', label: 'Настройки', icon: <Settings /> },
{ to: '/me/addresses', label: 'Адреса доставки', icon: <MapPin /> },
{ to: '/me/notifications', label: 'Оповещения', icon: <Bell /> },
{ to: '/me/notifications', label: 'Уведомления', icon: <Bell /> },
],
[],
)
-134
View File
@@ -1,134 +0,0 @@
import Alert from '@mui/material/Alert'
import Box from '@mui/material/Box'
import Button from '@mui/material/Button'
import Divider from '@mui/material/Divider'
import Stack from '@mui/material/Stack'
import TextField from '@mui/material/TextField'
import Typography from '@mui/material/Typography'
import { useUnit } from 'effector-react'
import { useForm } from 'react-hook-form'
import {
$requestEmailChangeCodeError,
$updateProfileError,
$user,
$verifyEmailChangeError,
requestEmailChangeCodeFx,
updateProfileFx,
verifyEmailChangeFx,
} from '@/shared/model/auth'
import type { AxiosError } from 'axios'
function getApiErrorMessage(error: unknown): string | null {
const e = error as AxiosError<{ error?: string }>
const msg = e?.response?.data?.error
return msg ? String(msg) : null
}
export function MePage() {
const user = useUnit($user)
const pendingEmailReq = useUnit(requestEmailChangeCodeFx.pending)
const pendingEmailVerify = useUnit(verifyEmailChangeFx.pending)
const pendingProfile = useUnit(updateProfileFx.pending)
const errorEmailReq = useUnit($requestEmailChangeCodeError)
const errorProfile = useUnit($updateProfileError)
const errorEmailVerify = useUnit($verifyEmailChangeError)
const emailForm = useForm<{ newEmail: string; code: string }>({
defaultValues: { newEmail: '', code: '' },
mode: 'onChange',
})
const profileForm = useForm<{ displayName: string }>({
defaultValues: { displayName: user?.displayName ? String(user.displayName) : '' },
mode: 'onChange',
})
const emailErrorMsg = getApiErrorMessage(errorEmailReq) ?? getApiErrorMessage(errorEmailVerify)
const profileErrorMsg = getApiErrorMessage(errorProfile)
if (!user) {
return <Alert severity="info">Нужно войти. Перейдите на страницу «Вход».</Alert>
}
return (
<Box>
<Typography variant="h4" gutterBottom>
Профиль
</Typography>
<Typography color="text.secondary" sx={{ mb: 3 }}>
Текущая почта: <b>{user.email}</b>
</Typography>
{emailErrorMsg && (
<Alert severity="error" sx={{ mb: 2 }}>
{emailErrorMsg}
</Alert>
)}
{profileErrorMsg && (
<Alert severity="error" sx={{ mb: 2 }}>
{profileErrorMsg}
</Alert>
)}
<Stack spacing={3} sx={{ maxWidth: 560 }}>
<Box>
<Typography variant="h6" gutterBottom>
Имя / ник
</Typography>
<Stack spacing={2}>
<TextField
label="Имя или ник"
helperText="До 40 символов"
slotProps={{ htmlInput: { maxLength: 40 } }}
{...profileForm.register('displayName')}
/>
<Button
variant="contained"
disabled={pendingProfile}
onClick={() => {
const raw = profileForm.getValues('displayName')
const name = raw.trim()
updateProfileFx({ displayName: name.length ? name : null })
}}
>
Сохранить имя
</Button>
</Stack>
</Box>
<Divider />
<Box>
<Typography variant="h6" gutterBottom>
Смена почты
</Typography>
<Stack spacing={2}>
<TextField label="Новая почта" {...emailForm.register('newEmail')} />
<Button
variant="outlined"
disabled={!emailForm.watch('newEmail') || pendingEmailReq}
onClick={() => requestEmailChangeCodeFx(emailForm.getValues('newEmail').trim())}
>
Отправить код на новую почту
</Button>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2}>
<TextField label="Код (6 цифр)" inputMode="numeric" {...emailForm.register('code')} />
<Button
variant="contained"
disabled={emailForm.watch('code').trim().length !== 6 || pendingEmailVerify}
onClick={() =>
verifyEmailChangeFx({
newEmail: emailForm.getValues('newEmail').trim(),
code: emailForm.getValues('code').trim(),
})
}
>
Подтвердить
</Button>
</Stack>
</Stack>
</Box>
</Stack>
</Box>
)
}
@@ -57,7 +57,7 @@ export function NotificationsPage() {
return (
<Box>
<Typography variant="h4" gutterBottom>
Оповещения
Уведомления
</Typography>
<Typography color="text.secondary" sx={{ mb: 3 }}>
Настройте, какие уведомления вы хотите получать на почту.
@@ -78,7 +78,7 @@ export function NotificationsPage() {
onChange={(e) => handleToggle('globalEnabled', e.target.checked)}
/>
}
label={<Typography sx={{ fontWeight: 600 }}>Получать оповещения</Typography>}
label={<Typography sx={{ fontWeight: 600 }}>Получать уведомления</Typography>}
/>
<Typography variant="body2" color="text.secondary" sx={{ ml: 4 }}>
Включите, чтобы получать уведомления о заказах на почту.
+3 -3
View File
@@ -1,7 +1,7 @@
import Box from '@mui/material/Box'
import Paper from '@mui/material/Paper'
import Typography from '@mui/material/Typography'
import { STORE_EMAIL, STORE_PUBLIC_SITE_URL } from '@/shared/config'
import { STORE_EMAIL, STORE_PHONE, STORE_PUBLIC_SITE_URL } from '@/shared/config'
const SITE_URL = STORE_PUBLIC_SITE_URL || (typeof window !== 'undefined' ? window.location.origin : '')
@@ -138,8 +138,8 @@ const sections = [
`ИНН: ${OP_INN}`,
`ОГРН: ${OP_OGRN}`,
`Адрес: ${OP_ADDR}`,
`Телефон: +7 (900) 000-00-00`, // TODO: заменить на реальный номер телефона
`Email: ${STORE_EMAIL}`, // TODO: заменить на реальный email при настройке STORE_EMAIL
`Телефон: ${STORE_PHONE}`,
`Email: ${STORE_EMAIL}`,
],
},
]
Binary file not shown.
+21 -1
View File
@@ -6,14 +6,20 @@ import {
import { prisma } from '../../lib/prisma.js'
export async function registerAdminCategoryRoutes(fastify) {
fastify.get('/api/admin/categories', { preHandler: [fastify.verifyAdmin] }, async () => {
fastify.get('/api/admin/categories', { preHandler: [fastify.verifyAdmin] }, async (request, reply) => {
try {
const items = await prisma.category.findMany({
orderBy: [{ sort: 'asc' }, { name: 'asc' }],
})
return { items }
} catch (err) {
request.log.error(err)
return reply.code(500).send({ error: 'Не удалось загрузить категории' })
}
})
fastify.post('/api/admin/categories', { preHandler: [fastify.verifyAdmin] }, async (request, reply) => {
try {
const body = request.body ?? {}
const name = String(body.name ?? '').trim()
if (!name) {
@@ -39,9 +45,14 @@ export async function registerAdminCategoryRoutes(fastify) {
},
})
reply.code(201).send(category)
} catch (err) {
request.log.error(err)
return reply.code(500).send({ error: 'Не удалось создать категорию' })
}
})
fastify.patch('/api/admin/categories/:id', { preHandler: [fastify.verifyAdmin] }, async (request, reply) => {
try {
const { id } = request.params
const body = request.body ?? {}
const existing = await prisma.category.findUnique({ where: { id } })
@@ -94,9 +105,14 @@ export async function registerAdminCategoryRoutes(fastify) {
const updated = await prisma.category.update({ where: { id }, data })
return updated
} catch (err) {
request.log.error(err)
return reply.code(500).send({ error: 'Не удалось обновить категорию' })
}
})
fastify.delete('/api/admin/categories/:id', { preHandler: [fastify.verifyAdmin] }, async (request, reply) => {
try {
const { id } = request.params
const existing = await prisma.category.findUnique({ where: { id } })
if (!existing) {
@@ -117,5 +133,9 @@ export async function registerAdminCategoryRoutes(fastify) {
prisma.category.delete({ where: { id } }),
])
return reply.code(204).send()
} catch (err) {
request.log.error(err)
return reply.code(500).send({ error: 'Не удалось удалить категорию' })
}
})
}
+17 -2
View File
@@ -3,7 +3,8 @@ import { prisma } from '../../lib/prisma.js'
const MAX_SLIDES = 20
export async function registerCatalogSliderRoutes(fastify) {
fastify.get('/api/catalog-slider', async () => {
fastify.get('/api/catalog-slider', async (request, reply) => {
try {
const slides = await prisma.catalogSliderSlide.findMany({
orderBy: { sortOrder: 'asc' },
include: { galleryImage: true },
@@ -15,9 +16,14 @@ export async function registerCatalogSliderRoutes(fastify) {
caption: s.caption,
})),
}
} catch (err) {
request.log.error(err)
return reply.code(500).send({ error: 'Не удалось загрузить слайдер' })
}
})
fastify.get('/api/admin/catalog-slider', { preHandler: [fastify.verifyAdmin] }, async () => {
fastify.get('/api/admin/catalog-slider', { preHandler: [fastify.verifyAdmin] }, async (request, reply) => {
try {
const slides = await prisma.catalogSliderSlide.findMany({
orderBy: { sortOrder: 'asc' },
include: { galleryImage: true },
@@ -30,9 +36,14 @@ export async function registerCatalogSliderRoutes(fastify) {
caption: s.caption,
})),
}
} catch (err) {
request.log.error(err)
return reply.code(500).send({ error: 'Не удалось загрузить слайдер' })
}
})
fastify.put('/api/admin/catalog-slider', { preHandler: [fastify.verifyAdmin] }, async (request, reply) => {
try {
const body = request.body ?? {}
const rawSlides = body.slides
if (!Array.isArray(rawSlides)) {
@@ -87,5 +98,9 @@ export async function registerCatalogSliderRoutes(fastify) {
caption: s.caption,
})),
}
} catch (err) {
request.log.error(err)
return reply.code(500).send({ error: 'Не удалось обновить слайдер' })
}
})
}
+1 -2
View File
@@ -123,8 +123,7 @@ export async function registerAuthRoutes(fastify) {
const avatarRaw = request.body?.avatar
const avatar = avatarRaw === null || avatarRaw === undefined ? undefined : String(avatarRaw).trim()
const avatarTypeRaw = request.body?.avatarType
const avatarType =
avatarTypeRaw === null || avatarTypeRaw === undefined ? undefined : String(avatarTypeRaw).trim()
const avatarType = avatarTypeRaw === null || avatarTypeRaw === undefined ? undefined : String(avatarTypeRaw).trim()
const avatarStyleRaw = request.body?.avatarStyle
const avatarStyle =
avatarStyleRaw === null || avatarStyleRaw === undefined ? undefined : String(avatarStyleRaw).trim()
+28 -2
View File
@@ -45,16 +45,22 @@ function validateAddressPayload(body, reply) {
}
export async function registerUserAddressRoutes(fastify) {
fastify.get('/api/me/addresses', { preHandler: [fastify.authenticate] }, async (request) => {
fastify.get('/api/me/addresses', { preHandler: [fastify.authenticate] }, async (request, reply) => {
try {
const userId = request.user.sub
const items = await prisma.shippingAddress.findMany({
where: { userId },
orderBy: [{ isDefault: 'desc' }, { updatedAt: 'desc' }],
})
return { items }
} catch (err) {
request.log.error(err)
return reply.code(500).send({ error: 'Не удалось загрузить адреса' })
}
})
fastify.post('/api/me/addresses', { preHandler: [fastify.authenticate] }, async (request, reply) => {
try {
const userId = request.user.sub
const validated = validateAddressPayload(request.body, reply)
if (!validated) return
@@ -73,9 +79,14 @@ export async function registerUserAddressRoutes(fastify) {
})
})
return reply.code(201).send({ item: created })
} catch (err) {
request.log.error(err)
return reply.code(500).send({ error: 'Не удалось создать адрес' })
}
})
fastify.patch('/api/me/addresses/:id', { preHandler: [fastify.authenticate] }, async (request, reply) => {
try {
const userId = request.user.sub
const { id } = request.params
const existing = await prisma.shippingAddress.findFirst({ where: { id, userId } })
@@ -123,7 +134,8 @@ export async function registerUserAddressRoutes(fastify) {
if (body.lat !== undefined) {
const lat = Number(body.lat)
if (!Number.isFinite(lat) || lat < -90 || lat > 90) return reply.code(400).send({ error: 'Некорректная широта' })
if (!Number.isFinite(lat) || lat < -90 || lat > 90)
return reply.code(400).send({ error: 'Некорректная широта' })
data.lat = lat
}
@@ -149,9 +161,14 @@ export async function registerUserAddressRoutes(fastify) {
})
return { item: updated }
} catch (err) {
request.log.error(err)
return reply.code(500).send({ error: 'Не удалось обновить адрес' })
}
})
fastify.delete('/api/me/addresses/:id', { preHandler: [fastify.authenticate] }, async (request, reply) => {
try {
const userId = request.user.sub
const { id } = request.params
const existing = await prisma.shippingAddress.findFirst({ where: { id, userId } })
@@ -159,9 +176,14 @@ export async function registerUserAddressRoutes(fastify) {
await prisma.shippingAddress.delete({ where: { id } })
return reply.code(204).send()
} catch (err) {
request.log.error(err)
return reply.code(500).send({ error: 'Не удалось удалить адрес' })
}
})
fastify.post('/api/me/addresses/:id/default', { preHandler: [fastify.authenticate] }, async (request, reply) => {
try {
const userId = request.user.sub
const { id } = request.params
const existing = await prisma.shippingAddress.findFirst({ where: { id, userId } })
@@ -173,5 +195,9 @@ export async function registerUserAddressRoutes(fastify) {
})
return { item: updated }
} catch (err) {
request.log.error(err)
return reply.code(500).send({ error: 'Не удалось установить адрес по умолчанию' })
}
})
}
+21 -1
View File
@@ -1,7 +1,8 @@
import { prisma } from '../lib/prisma.js'
export async function registerUserCartRoutes(fastify) {
fastify.get('/api/me/cart', { preHandler: [fastify.authenticate] }, async (request) => {
fastify.get('/api/me/cart', { preHandler: [fastify.authenticate] }, async (request, reply) => {
try {
const userId = request.user.sub
const items = await prisma.cartItem.findMany({
where: { userId },
@@ -15,9 +16,14 @@ export async function registerUserCartRoutes(fastify) {
product: x.product,
})),
}
} catch (err) {
request.log.error(err)
return reply.code(500).send({ error: 'Не удалось загрузить корзину' })
}
})
fastify.post('/api/me/cart/items', { preHandler: [fastify.authenticate] }, async (request, reply) => {
try {
const userId = request.user.sub
const productId = String(request.body?.productId || '').trim()
const qtyRaw = request.body?.qty
@@ -40,9 +46,14 @@ export async function registerUserCartRoutes(fastify) {
create: { userId, productId, qty: nextQty },
})
return reply.code(201).send({ item })
} catch (err) {
request.log.error(err)
return reply.code(500).send({ error: 'Не удалось добавить в корзину' })
}
})
fastify.patch('/api/me/cart/items/:id', { preHandler: [fastify.authenticate] }, async (request, reply) => {
try {
const userId = request.user.sub
const { id } = request.params
const qtyRaw = request.body?.qty
@@ -63,14 +74,23 @@ export async function registerUserCartRoutes(fastify) {
const updated = await prisma.cartItem.update({ where: { id }, data: { qty: nextQty } })
return { item: updated }
} catch (err) {
request.log.error(err)
return reply.code(500).send({ error: 'Не удалось обновить количество' })
}
})
fastify.delete('/api/me/cart/items/:id', { preHandler: [fastify.authenticate] }, async (request, reply) => {
try {
const userId = request.user.sub
const { id } = request.params
const existing = await prisma.cartItem.findFirst({ where: { id, userId } })
if (!existing) return reply.code(404).send({ error: 'Позиция корзины не найдена' })
await prisma.cartItem.delete({ where: { id } })
return reply.code(204).send()
} catch (err) {
request.log.error(err)
return reply.code(500).send({ error: 'Не удалось удалить из корзины' })
}
})
}
+25 -19
View File
@@ -44,22 +44,21 @@ export async function registerUserMessageRoutes(fastify) {
})
if (orders.length === 0) return { count: 0 }
const orderIds = orders.map((o) => o.id)
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 },
},
const adminMessages = await prisma.orderMessage.findMany({
where: { orderId: { in: orderIds }, authorType: 'admin' },
select: { orderId: true, createdAt: true },
})
count += n
let count = 0
for (const msg of adminMessages) {
const lastRead = lastReadByOrder.get(msg.orderId) ?? new Date(0)
if (msg.createdAt > lastRead) count++
}
return { count }
})
@@ -86,25 +85,32 @@ export async function registerUserMessageRoutes(fastify) {
})
const lastReadByOrder = new Map(readStates.map((r) => [r.orderId, r.lastReadAt]))
const orderIds = orders.map((o) => o.id)
const unreadCounts = new Map()
if (orderIds.length > 0) {
const adminMessages = await prisma.orderMessage.findMany({
where: { orderId: { in: orderIds }, authorType: 'admin' },
select: { orderId: true, createdAt: true },
})
for (const msg of adminMessages) {
const lastRead = lastReadByOrder.get(msg.orderId) ?? new Date(0)
if (msg.createdAt > lastRead) {
unreadCounts.set(msg.orderId, (unreadCounts.get(msg.orderId) ?? 0) + 1)
}
}
}
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,
unreadCount: unreadCounts.get(o.id) ?? 0,
})
}
return { items }
+17 -2
View File
@@ -176,11 +176,12 @@ export async function registerUserOrderRoutes(fastify) {
return reply.code(201).send({ orderId: created.id })
})
fastify.get('/api/me/orders', { preHandler: [fastify.authenticate] }, async (request) => {
fastify.get('/api/me/orders', { preHandler: [fastify.authenticate] }, async (request, reply) => {
try {
const userId = request.user.sub
const orders = await prisma.order.findMany({
where: { userId },
include: { items: true },
include: { items: { select: { qty: true } } },
orderBy: { createdAt: 'desc' },
})
return {
@@ -194,9 +195,14 @@ export async function registerUserOrderRoutes(fastify) {
itemsCount: o.items.reduce((s, i) => s + i.qty, 0),
})),
}
} catch (err) {
request.log.error(err)
return reply.code(500).send({ error: 'Не удалось загрузить заказы' })
}
})
fastify.get('/api/me/orders/:id', { preHandler: [fastify.authenticate] }, async (request, reply) => {
try {
const userId = request.user.sub
const { id } = request.params
const order = await prisma.order.findFirst({
@@ -205,6 +211,10 @@ export async function registerUserOrderRoutes(fastify) {
})
if (!order) return reply.code(404).send({ error: 'Заказ не найден' })
return { item: order }
} catch (err) {
request.log.error(err)
return reply.code(500).send({ error: 'Не удалось загрузить заказ' })
}
})
fastify.get(
@@ -251,6 +261,7 @@ export async function registerUserOrderRoutes(fastify) {
'/api/me/orders/:id/confirm-received',
{ preHandler: [fastify.authenticate] },
async (request, reply) => {
try {
const userId = request.user.sub
const { id } = request.params
const order = await prisma.order.findFirst({ where: { id, userId } })
@@ -264,6 +275,10 @@ export async function registerUserOrderRoutes(fastify) {
await prisma.order.update({ where: { id }, data: { status: 'DONE' } })
return { ok: true, status: 'DONE' }
} catch (err) {
request.log.error(err)
return reply.code(500).send({ error: 'Не удалось подтвердить получение' })
}
},
)
}