From 88fedd675a0eb16b84d99dea6c45c11bf6c20c77 Mon Sep 17 00:00:00 2001 From: Kirill Date: Sun, 24 May 2026 15:10:24 +0500 Subject: [PATCH 1/2] =?UTF-8?q?=D0=BF=D0=B2=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/public/robots.txt | 4 + client/public/sitemap.xml | 28 +++++++ .../pages/info/ui/sections/PaymentSection.tsx | 6 +- .../pages/info/ui/sections/ReturnsSection.tsx | 8 +- client/src/shared/lib/order-status-data.ts | 12 ++- client/src/shared/ui/OrderStatusChip.tsx | 77 ++++++++++++++++-- scripts/SERVER_SETUP.md | 25 ++++++ scripts/backup-db.sh | 41 ++++++++++ scripts/craftshop-backup.service | 7 ++ scripts/craftshop-backup.timer | 9 ++ server/prisma/prisma/dev.db | Bin 364544 -> 364544 bytes server/src/index.js | 41 +++++++++- server/src/lib/generate-avatar.js | 2 +- server/src/lib/rate-limit.js | 43 ++++++++-- server/src/plugins/security-headers.js | 24 ++++++ .../src/routes/__tests__/oauth-social.test.js | 23 ++++-- server/src/routes/__tests__/sse.test.js | 9 +- server/src/routes/auth.js | 20 ++++- 18 files changed, 347 insertions(+), 32 deletions(-) create mode 100644 client/public/robots.txt create mode 100644 client/public/sitemap.xml create mode 100644 scripts/backup-db.sh create mode 100644 scripts/craftshop-backup.service create mode 100644 scripts/craftshop-backup.timer create mode 100644 server/src/plugins/security-headers.js diff --git a/client/public/robots.txt b/client/public/robots.txt new file mode 100644 index 0000000..bcb400c --- /dev/null +++ b/client/public/robots.txt @@ -0,0 +1,4 @@ +User-agent: * +Allow: / + +Sitemap: https://любимыйкреатив.рф/sitemap.xml diff --git a/client/public/sitemap.xml b/client/public/sitemap.xml new file mode 100644 index 0000000..43a2a44 --- /dev/null +++ b/client/public/sitemap.xml @@ -0,0 +1,28 @@ + + + + https://любимыйкреатив.рф/ + 1.0 + daily + + + https://любимыйкреатив.рф/info + 0.8 + monthly + + + https://любимыйкреатив.рф/about + 0.7 + monthly + + + https://любимыйкреатив.рф/privacy + 0.5 + yearly + + + https://любимыйкреатив.рф/terms + 0.5 + yearly + + diff --git a/client/src/pages/info/ui/sections/PaymentSection.tsx b/client/src/pages/info/ui/sections/PaymentSection.tsx index 0e43938..e9f118f 100644 --- a/client/src/pages/info/ui/sections/PaymentSection.tsx +++ b/client/src/pages/info/ui/sections/PaymentSection.tsx @@ -7,7 +7,8 @@ const methods = [ { icon: , primary: 'Онлайн-оплата через ЮKassa', - secondary: 'Карты, СБП. Оплата после подтверждения заказа админом. Перенаправление на защищённую платёжную страницу.', + secondary: + 'Карты, СБП. Оплата после подтверждения заказа админом. Перенаправление на защищённую платёжную страницу.', }, { icon: , @@ -39,7 +40,8 @@ export function PaymentSection() { maxWidth: '56ch', }} > - Оплата происходит после подтверждения заказа админом. Вы получите уведомление, когда заказ будет подтверждён и готов к оплате. + Оплата происходит после подтверждения заказа админом. Вы получите уведомление, когда заказ будет подтверждён и + готов к оплате. diff --git a/client/src/pages/info/ui/sections/ReturnsSection.tsx b/client/src/pages/info/ui/sections/ReturnsSection.tsx index 63fc641..5561115 100644 --- a/client/src/pages/info/ui/sections/ReturnsSection.tsx +++ b/client/src/pages/info/ui/sections/ReturnsSection.tsx @@ -36,7 +36,9 @@ export function ReturnsSection() { lineHeight: 1.65, }} > - Если товар не соответствует описанию или имеет производственный дефект, свяжитесь с нами в течение 7 дней после получения. Мы заменим изделие на аналогичное или вернём деньги. Возврат товара надлежащего качества возможен в течение 14 дней, если изделие не было в употреблении и сохранён его товарный вид. + Если товар не соответствует описанию или имеет производственный дефект, свяжитесь с нами в течение 7 дней + после получения. Мы заменим изделие на аналогичное или вернём деньги. Возврат товара надлежащего качества + возможен в течение 14 дней, если изделие не было в употреблении и сохранён его товарный вид. @@ -58,7 +60,9 @@ export function ReturnsSection() { lineHeight: 1.65, }} > - Мы отвечаем за качество каждого изделия ручной работы. Все дефекты, возникшие не по вине покупателя, устраняются или компенсируются заменой изделия. Если у вас возникли вопросы по качеству — напишите нам, и мы решим проблему в кратчайшие сроки. + Мы отвечаем за качество каждого изделия ручной работы. Все дефекты, возникшие не по вине покупателя, + устраняются или компенсируются заменой изделия. Если у вас возникли вопросы по качеству — напишите нам, и мы + решим проблему в кратчайшие сроки. diff --git a/client/src/shared/lib/order-status-data.ts b/client/src/shared/lib/order-status-data.ts index 5cb51b6..18c5c39 100644 --- a/client/src/shared/lib/order-status-data.ts +++ b/client/src/shared/lib/order-status-data.ts @@ -1,5 +1,12 @@ export type StatusColor = 'warning' | 'success' | 'info' | 'error' -export type StatusIconName = 'banknote' | 'check-circle' | 'package-search' | 'package' | 'package-check' | 'store' | 'x-circle' +export type StatusIconName = + | 'banknote' + | 'check-circle' + | 'package-search' + | 'package' + | 'package-check' + | 'store' + | 'x-circle' export interface OrderStatusData { code: string @@ -37,7 +44,8 @@ export const ORDER_STATUS_DATA: ReadonlyArray = [ label: 'Отправлен', iconName: 'package', color: 'info', - description: 'Заказ передан в службу доставки. Трек-номер для отслеживания(при наличии) будет указан в сообщении админа.', + description: + 'Заказ передан в службу доставки. Трек-номер для отслеживания(при наличии) будет указан в сообщении админа.', }, { code: 'READY_FOR_PICKUP', diff --git a/client/src/shared/ui/OrderStatusChip.tsx b/client/src/shared/ui/OrderStatusChip.tsx index 5e50236..35bc79b 100644 --- a/client/src/shared/ui/OrderStatusChip.tsx +++ b/client/src/shared/ui/OrderStatusChip.tsx @@ -6,20 +6,47 @@ import { getOrderStatusData, type StatusIconName } from '@/shared/lib/order-stat const iconMap: Record = { banknote: ( - + ), 'check-circle': ( - + ), 'package-search': ( - + @@ -27,14 +54,32 @@ const iconMap: Record = { ), package: ( - + ), 'package-check': ( - + @@ -42,13 +87,31 @@ const iconMap: Record = { ), store: ( - + ), 'x-circle': ( - + diff --git a/scripts/SERVER_SETUP.md b/scripts/SERVER_SETUP.md index 78d1294..abf3600 100644 --- a/scripts/SERVER_SETUP.md +++ b/scripts/SERVER_SETUP.md @@ -169,3 +169,28 @@ curl http://127.0.0.1:3333/health ```bash curl https://craftshop.твой-домен/api/health ``` + +## 9. Бэкапы БД (systemd timer) + +Установить таймер для автоматического бэкапа каждые 6 часов: + +```bash +# Установить sqlite3 для безопасного копирования +apt-get install -y sqlite3 + +# Скопировать unit-файлы +cp /opt/craftshop/scripts/craftshop-backup.service /etc/systemd/system/ +cp /opt/craftshop/scripts/craftshop-backup.timer /etc/systemd/system/ + +systemctl daemon-reload +systemctl enable --now craftshop-backup.timer + +# Проверить статус +systemctl list-timers craftshop-backup.timer + +# Ручной запуск для проверки +systemctl start craftshop-backup.service +ls /opt/craftshop/server/backups/ +``` + +Бэкапы хранятся 30 дней (настраивается в `scripts/backup-db.sh`). diff --git a/scripts/backup-db.sh b/scripts/backup-db.sh new file mode 100644 index 0000000..ec5a84c --- /dev/null +++ b/scripts/backup-db.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash +# Backup SQLite database — копирует .db файл с timestamp в директорию бэкапов. +# Вызывается из systemd timer или cron. +# +# Использование: ./scripts/backup-db.sh [path-to-db] [backup-dir] [retention-days] +# По умолчанию: db=server/prisma/prod.db, backup=server/backups, retention=30 + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" + +DB_PATH="${1:-$ROOT/server/prisma/prod.db}" +BACKUP_DIR="${2:-$ROOT/server/backups}" +RETENTION_DAYS="${3:-30}" + +if [[ ! -f "$DB_PATH" ]]; then + echo "[$(date -Iseconds)] DB not found: $DB_PATH" >&2 + exit 1 +fi + +mkdir -p "$BACKUP_DIR" + +TIMESTAMP=$(date +%Y%m%d_%H%M%S) +BACKUP_FILE="$BACKUP_DIR/craftshop_${TIMESTAMP}.db" + +# SQLite-safe copy: use .backup to avoid copying a file mid-write +if command -v sqlite3 &>/dev/null; then + sqlite3 "$DB_PATH" ".backup '$BACKUP_FILE'" +else + # Fallback: plain copy (risk of inconsistent state if DB is being written) + cp "$DB_PATH" "$BACKUP_FILE" +fi + +# Compress +gzip -f "$BACKUP_FILE" + +# Remove old backups +find "$BACKUP_DIR" -name 'craftshop_*.db.gz' -mtime +"$RETENTION_DAYS" -delete + +echo "[$(date -Iseconds)] Backup created: ${BACKUP_FILE}.gz" diff --git a/scripts/craftshop-backup.service b/scripts/craftshop-backup.service new file mode 100644 index 0000000..9f8f3b9 --- /dev/null +++ b/scripts/craftshop-backup.service @@ -0,0 +1,7 @@ +[Unit] +Description=Craftshop SQLite Database Backup +After=network.target + +[Service] +Type=oneshot +ExecStart=/opt/craftshop/scripts/backup-db.sh /opt/craftshop/server/prisma/prod.db /opt/craftshop/server/backups 30 diff --git a/scripts/craftshop-backup.timer b/scripts/craftshop-backup.timer new file mode 100644 index 0000000..6655abc --- /dev/null +++ b/scripts/craftshop-backup.timer @@ -0,0 +1,9 @@ +[Unit] +Description=Craftshop Database Backup Timer + +[Timer] +OnCalendar=*-*-* 00/6:00:00 +Persistent=true + +[Install] +WantedBy=timers.target diff --git a/server/prisma/prisma/dev.db b/server/prisma/prisma/dev.db index d8c0596c0c4a78c3be9f35f68f8db925eb207a6f..72c18a15770083e6197ffa657151ccc335f757e9 100644 GIT binary patch delta 2580 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 diff --git a/server/src/index.js b/server/src/index.js index cba933c..f7f8e0b 100644 --- a/server/src/index.js +++ b/server/src/index.js @@ -19,13 +19,14 @@ import { prisma } from './lib/prisma.js' import { getMaxUploadBodyBytes, getProductImageMaxFileBytes } from './lib/upload-limits.js' import { registerAuth } from './plugins/auth.js' import { registerIpGate } from './plugins/ip-gate.js' +import { registerSecurityHeaders } from './plugins/security-headers.js' import { registerApiRoutes } from './routes/api.js' import { registerOAuthSocialRoutes } from './routes/oauth-social.js' +import { registerSseRoutes } from './routes/sse.js' import { registerUploadsResized } from './routes/uploads-resized.js' import { registerUserNotificationRoutes } from './routes/user/notifications.js' import { registerUserAddressRoutes } from './routes/user-addresses.js' import { registerUserCartRoutes } from './routes/user-cart.js' -import { registerSseRoutes } from './routes/sse.js' import { registerUserMessageRoutes } from './routes/user-messages.js' import { registerUserOrderRoutes } from './routes/user-orders.js' import { registerUserPaymentRoutes } from './routes/user-payments.js' @@ -48,6 +49,44 @@ await fastify.register(cors, { credentials: true, }) +await registerSecurityHeaders(fastify) + +fastify.get('/health', async () => { + try { + await prisma.$queryRaw`SELECT 1` + return { status: 'ok', database: 'connected', uptime: process.uptime() } + } catch { + return { status: 'degraded', database: 'disconnected', uptime: process.uptime() } + } +}) + +fastify.setErrorHandler(function errorHandler(error, request, reply) { + const isProd = process.env.NODE_ENV === 'production' + + if (error.validation) { + return reply.code(400).send({ + error: 'Ошибка валидации', + details: isProd ? undefined : error.validation, + }) + } + + if (error.code === 'FST_ERR_VALIDATION') { + return reply.code(400).send({ error: 'Неверный формат запроса' }) + } + + if (error.statusCode) { + return reply.code(error.statusCode).send({ + error: error.message || 'Произошла ошибка', + }) + } + + request.log.error(error) + + return reply.code(500).send({ + error: isProd ? 'Внутренняя ошибка сервера' : error.message, + }) +}) + await fastify.register(jwt, { secret: process.env.JWT_SECRET || 'dev-jwt-secret-change-me', }) diff --git a/server/src/lib/generate-avatar.js b/server/src/lib/generate-avatar.js index e95d905..cbc2680 100644 --- a/server/src/lib/generate-avatar.js +++ b/server/src/lib/generate-avatar.js @@ -1,5 +1,5 @@ -import { createAvatar } from '@dicebear/core' import { avataaars } from '@dicebear/collection' +import { createAvatar } from '@dicebear/core' const DEFAULT_STYLE = avataaars diff --git a/server/src/lib/rate-limit.js b/server/src/lib/rate-limit.js index 7198aaa..0f09b1f 100644 --- a/server/src/lib/rate-limit.js +++ b/server/src/lib/rate-limit.js @@ -1,26 +1,51 @@ const windows = new Map() -const MAX_ATTEMPTS = 5 -const WINDOW_MS = 60_000 +const DEFAULT_MAX_ATTEMPTS = 5 +const DEFAULT_WINDOW_MS = 60_000 + +// Per-endpoint rate limits +const LIMITS = { + login: { maxAttempts: 5, windowMs: 60_000 }, + codeRequest: { maxAttempts: 3, windowMs: 60_000 }, + codeVerify: { maxAttempts: 5, windowMs: 60_000 }, +} setInterval(() => { const now = Date.now() for (const [ip, entry] of windows) { - if (now - entry.start > WINDOW_MS) windows.delete(ip) + if (now - entry.start > DEFAULT_WINDOW_MS) windows.delete(ip) } }, 5 * 60_000).unref() -export function checkLoginRateLimit(ip) { +function getKey(ip, scope) { + return `${scope}:${ip}` +} + +function checkRateLimit(ip, scope) { + const limit = LIMITS[scope] || { maxAttempts: DEFAULT_MAX_ATTEMPTS, windowMs: DEFAULT_WINDOW_MS } + const key = getKey(ip, scope) const now = Date.now() - const entry = windows.get(ip) - if (!entry || now - entry.start > WINDOW_MS) { - windows.set(ip, { start: now, count: 1 }) + const entry = windows.get(key) + if (!entry || now - entry.start > limit.windowMs) { + windows.set(key, { start: now, count: 1 }) return { allowed: true } } entry.count += 1 - if (entry.count > MAX_ATTEMPTS) { - const retryAfter = Math.ceil((entry.start + WINDOW_MS - now) / 1000) + if (entry.count > limit.maxAttempts) { + const retryAfter = Math.ceil((entry.start + limit.windowMs - now) / 1000) return { allowed: false, retryAfter } } return { allowed: true } } + +export function checkLoginRateLimit(ip) { + return checkRateLimit(ip, 'login') +} + +export function checkCodeRequestRateLimit(ip) { + return checkRateLimit(ip, 'codeRequest') +} + +export function checkCodeVerifyRateLimit(ip) { + return checkRateLimit(ip, 'codeVerify') +} diff --git a/server/src/plugins/security-headers.js b/server/src/plugins/security-headers.js new file mode 100644 index 0000000..283ebc3 --- /dev/null +++ b/server/src/plugins/security-headers.js @@ -0,0 +1,24 @@ +export async function registerSecurityHeaders(fastify) { + fastify.addHook('onSend', async (request, reply) => { + reply.header('X-Content-Type-Options', 'nosniff') + reply.header('X-Frame-Options', 'DENY') + reply.header('X-XSS-Protection', '0') + reply.header('Referrer-Policy', 'strict-origin-when-cross-origin') + reply.header('Permissions-Policy', 'camera=(), microphone=(), geolocation=()') + + const cspDirectives = [ + "default-src 'self'", + "script-src 'self' https://*.yookassa.ru https://*.vk.com https://oauth.yandex.ru", + "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com", + "img-src 'self' data: blob: https://tile.openstreetmap.org https://*.yookassa.ru https://*.vk.com https://oauth.yandex.ru", + "font-src 'self' https://fonts.gstatic.com", + "connect-src 'self' https://*.yookassa.ru https://*.vk.com https://oauth.yandex.ru", + 'frame-src https://*.yookassa.ru', + "object-src 'none'", + "base-uri 'self'", + "form-action 'self'", + ].join('; ') + + reply.header('Content-Security-Policy', cspDirectives) + }) +} diff --git a/server/src/routes/__tests__/oauth-social.test.js b/server/src/routes/__tests__/oauth-social.test.js index e4dc118..d311075 100644 --- a/server/src/routes/__tests__/oauth-social.test.js +++ b/server/src/routes/__tests__/oauth-social.test.js @@ -1,7 +1,20 @@ -import { describe, it, expect } from 'vitest' +import { describe, it, expect, afterEach } from 'vitest' import { prisma } from '../../lib/prisma.js' describe('OAuth — User model fields', () => { + const createdIds = [] + + afterEach(async () => { + for (const id of createdIds) { + try { + await prisma.user.delete({ where: { id } }) + } catch { + // Already deleted by another test or cleanup — ignore + } + } + createdIds.length = 0 + }) + it('stores displayName and avatar fields on User model', async () => { const user = await prisma.user.create({ data: { @@ -11,10 +24,10 @@ describe('OAuth — User model fields', () => { }, }) + createdIds.push(user.id) + expect(user.displayName).toBe('Test User') expect(user.avatar).toBe('https://example.com/avatar.jpg') - - await prisma.user.delete({ where: { id: user.id } }) }) it('allows nullable fields', async () => { @@ -24,9 +37,9 @@ describe('OAuth — User model fields', () => { }, }) + createdIds.push(user.id) + expect(user.displayName).toBeNull() expect(user.avatar).toBeNull() - - await prisma.user.delete({ where: { id: user.id } }) }) }) diff --git a/server/src/routes/__tests__/sse.test.js b/server/src/routes/__tests__/sse.test.js index 082f34d..da79a2e 100644 --- a/server/src/routes/__tests__/sse.test.js +++ b/server/src/routes/__tests__/sse.test.js @@ -1,5 +1,5 @@ -import Fastify from 'fastify' import { EventEmitter } from 'node:events' +import Fastify from 'fastify' import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' import { buildSseListeners, formatHeartbit, formatSSE, isAdminUser, registerSseRoutes } from '../sse.js' @@ -84,7 +84,12 @@ describe('buildSseListeners', () => { it('forwards order:statusChanged to matching userId', () => { const cleanup = buildSseListeners('user-1', false, eventBus, write) - eventBus.emit('order:statusChanged', { orderId: 'o1', userId: 'user-1', oldStatus: 'PENDING_PAYMENT', newStatus: 'PAID' }) + eventBus.emit('order:statusChanged', { + orderId: 'o1', + userId: 'user-1', + oldStatus: 'PENDING_PAYMENT', + newStatus: 'PAID', + }) expect(write).toHaveBeenCalledTimes(1) expect(write.mock.calls[0][0]).toContain('event: order:statusChanged') expect(write.mock.calls[0][0]).toContain('"newStatus":"PAID"') diff --git a/server/src/routes/auth.js b/server/src/routes/auth.js index 54a36c3..cf7185b 100644 --- a/server/src/routes/auth.js +++ b/server/src/routes/auth.js @@ -10,7 +10,7 @@ import { } from '../lib/auth.js' import { generateAvatar } from '../lib/generate-avatar.js' import { prisma } from '../lib/prisma.js' -import { checkLoginRateLimit } from '../lib/rate-limit.js' +import { checkCodeRequestRateLimit, checkCodeVerifyRateLimit, checkLoginRateLimit } from '../lib/rate-limit.js' export function mapUserForClient(user) { const adminEmail = normalizeEmail(process.env.ADMIN_EMAIL) @@ -30,6 +30,15 @@ export async function registerAuthRoutes(fastify) { const email = normalizeEmail(request.body?.email) if (!email || !email.includes('@')) return reply.code(400).send({ error: 'Некорректная почта' }) + const ip = request.ip + const rate = checkCodeRequestRateLimit(ip) + if (!rate.allowed) { + return reply + .code(429) + .header('Retry-After', String(rate.retryAfter)) + .send({ error: `Слишком много запросов. Попробуйте через ${rate.retryAfter} сек.` }) + } + const code = await issueEmailCode({ email, purpose: 'login' }) const adminEmail = process.env.ADMIN_EMAIL?.trim().toLowerCase() @@ -50,6 +59,15 @@ export async function registerAuthRoutes(fastify) { if (!email || !email.includes('@')) return reply.code(400).send({ error: 'Некорректная почта' }) if (!code || code.length !== 6) return reply.code(400).send({ error: 'Код должен быть из 6 цифр' }) + const ip = request.ip + const rate = checkCodeVerifyRateLimit(ip) + if (!rate.allowed) { + return reply + .code(429) + .header('Retry-After', String(rate.retryAfter)) + .send({ error: `Слишком много попыток. Попробуйте через ${rate.retryAfter} сек.` }) + } + const ok = await verifyEmailCode({ email, purpose: 'login', code }) if (!ok) return reply.code(401).send({ error: 'Неверный или истёкший код' }) From 96f06c79b49fce8916b8798c2285c2bfd1d8feef Mon Sep 17 00:00:00 2001 From: Kirill Date: Sun, 24 May 2026 15:48:33 +0500 Subject: [PATCH 2/2] =?UTF-8?q?=D0=BF=D0=B2=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/app/routes/index.tsx | 3 ++ .../src/entities/product/ui/ProductCard.tsx | 30 ++++++++++-------- client/src/pages/about/ui/AboutPage.tsx | 2 ++ client/src/pages/cart/ui/CartPage.tsx | 2 ++ client/src/pages/info/ui/InfoPage.tsx | 2 ++ .../pages/me/ui/sections/AddressesPage.tsx | 2 ++ .../src/pages/me/ui/sections/MessagesPage.tsx | 2 ++ .../me/ui/sections/NotificationsPage.tsx | 2 ++ .../src/pages/me/ui/sections/OrdersPage.tsx | 2 ++ .../src/pages/me/ui/sections/SettingsPage.tsx | 2 ++ .../privacy-policy/ui/PrivacyPolicyPage.tsx | 2 ++ client/src/pages/product/ui/ProductPage.tsx | 3 ++ client/src/pages/terms/ui/TermsPage.tsx | 2 ++ client/src/shared/lib/use-page-title.ts | 22 +++++++++++++ server/prisma/prisma/dev.db | Bin 364544 -> 364544 bytes 15 files changed, 65 insertions(+), 13 deletions(-) create mode 100644 client/src/shared/lib/use-page-title.ts diff --git a/client/src/app/routes/index.tsx b/client/src/app/routes/index.tsx index 5e9177e..1f34896 100644 --- a/client/src/app/routes/index.tsx +++ b/client/src/app/routes/index.tsx @@ -11,12 +11,15 @@ import { NotFoundPage } from '@/pages/not-found' import { PrivacyPolicyPage } from '@/pages/privacy-policy' import { ProductPage } from '@/pages/product' import { TermsPage } from '@/pages/terms' +import { usePageTitleReset } from '@/shared/lib/use-page-title' import { SkeletonPage } from '@/shared/ui/SkeletonPage' const AdminLayoutPage = lazy(() => import('@/pages/admin-layout').then((m) => ({ default: m.AdminLayoutPage }))) const MeLayoutPage = lazy(() => import('@/pages/me').then((m) => ({ default: m.MeLayoutPage }))) export function AppRoutes() { + usePageTitleReset() + return ( diff --git a/client/src/entities/product/ui/ProductCard.tsx b/client/src/entities/product/ui/ProductCard.tsx index 3e1aa31..0322d3a 100644 --- a/client/src/entities/product/ui/ProductCard.tsx +++ b/client/src/entities/product/ui/ProductCard.tsx @@ -1,13 +1,14 @@ import type { ReactNode } from 'react' import { useCallback, useMemo, useRef } from 'react' +import { useMediaQuery } from '@mui/material' import Box from '@mui/material/Box' import Card from '@mui/material/Card' -import CardContent from '@mui/material/CardContent' import CardMedia from '@mui/material/CardMedia' import Chip from '@mui/material/Chip' import Stack from '@mui/material/Stack' import Typography from '@mui/material/Typography' import { useNavigate } from 'react-router-dom' +import { Autoplay } from 'swiper/modules' import { Swiper, SwiperSlide } from 'swiper/react' import 'swiper/css' import type { Product } from '@/entities/product/model/types' @@ -19,6 +20,7 @@ type Props = { product: Product; mediaHeight?: number; actions?: ReactNode } export function ProductCard({ product, mediaHeight = 200, actions }: Props) { const navigate = useNavigate() + const isMobile = useMediaQuery('(max-width:600px)') const swiperRef = useRef(null) const imageUrls = useMemo(() => { const fromImages = (product.images ?? []) @@ -76,12 +78,14 @@ export function ProductCard({ product, mediaHeight = 200, actions }: Props) { > {imageUrls.length ? ( - + { swiperRef.current = s }} - allowTouchMove={false} + modules={isMobile ? [Autoplay] : undefined} + autoplay={isMobile ? { delay: 3000, disableOnInteraction: false, pauseOnMouseEnter: true } : undefined} + allowTouchMove={!isMobile} style={{ width: '100%', height: mediaHeight }} > {imageUrls.map((url) => ( @@ -150,8 +154,8 @@ export function ProductCard({ product, mediaHeight = 200, actions }: Props) { )} - - + + {product.category && ( {product.shortDescription ?? 'Описание появится позже.'} - - - - {formatPriceRub(product.priceCents)} - - {actions} - - + + + + {formatPriceRub(product.priceCents)} + + {actions} + + ) } diff --git a/client/src/pages/about/ui/AboutPage.tsx b/client/src/pages/about/ui/AboutPage.tsx index bfeac6f..1e2e25b 100644 --- a/client/src/pages/about/ui/AboutPage.tsx +++ b/client/src/pages/about/ui/AboutPage.tsx @@ -7,6 +7,7 @@ import * as maplibregl from 'maplibre-gl' import Map, { Marker } from 'react-map-gl/maplibre' import { STORE_EMAIL, STORE_PHONE, VK_URL } from '@/shared/config' import { PICKUP_ADDRESS_FULL, PICKUP_COORDINATES } from '@/shared/constants/pickup-point' +import { usePageTitle } from '@/shared/lib/use-page-title' const rasterStyle = { version: 8 as const, @@ -22,6 +23,7 @@ const rasterStyle = { } export function AboutPage() { + usePageTitle('О нас') const { lat, lng } = PICKUP_COORDINATES return ( diff --git a/client/src/pages/cart/ui/CartPage.tsx b/client/src/pages/cart/ui/CartPage.tsx index b849e78..fe4ece2 100644 --- a/client/src/pages/cart/ui/CartPage.tsx +++ b/client/src/pages/cart/ui/CartPage.tsx @@ -12,9 +12,11 @@ import { Minus, Plus, Trash2 } from 'lucide-react' import { Link as RouterLink } from 'react-router-dom' import { fetchMyCart, removeCartItem, setCartQty } from '@/entities/cart/api/cart-api' import { formatPriceRub } from '@/shared/lib/format-price' +import { usePageTitle } from '@/shared/lib/use-page-title' import { $user } from '@/shared/model/auth' export function CartPage() { + usePageTitle('Корзина') const user = useUnit($user) const qc = useQueryClient() diff --git a/client/src/pages/info/ui/InfoPage.tsx b/client/src/pages/info/ui/InfoPage.tsx index 7f47023..2f66c95 100644 --- a/client/src/pages/info/ui/InfoPage.tsx +++ b/client/src/pages/info/ui/InfoPage.tsx @@ -3,6 +3,7 @@ import Container from '@mui/material/Container' import Divider from '@mui/material/Divider' import Stack from '@mui/material/Stack' import Typography from '@mui/material/Typography' +import { usePageTitle } from '@/shared/lib/use-page-title' import { DeliverySection } from './sections/DeliverySection' import { HowToOrderSection } from './sections/HowToOrderSection' import { OrderStatusesSection } from './sections/OrderStatusesSection' @@ -10,6 +11,7 @@ import { PaymentSection } from './sections/PaymentSection' import { ReturnsSection } from './sections/ReturnsSection' export function InfoPage() { + usePageTitle('Информация для покупателей') return ( {/* Hero */} diff --git a/client/src/pages/me/ui/sections/AddressesPage.tsx b/client/src/pages/me/ui/sections/AddressesPage.tsx index 8535e09..16a834f 100644 --- a/client/src/pages/me/ui/sections/AddressesPage.tsx +++ b/client/src/pages/me/ui/sections/AddressesPage.tsx @@ -16,6 +16,7 @@ import { } from '@/entities/user/api/address-api' import type { ShippingAddress } from '@/entities/user/model/types' import { AddressFormDialog, type AddressFormValues } from '@/features/address-form' +import { usePageTitle } from '@/shared/lib/use-page-title' const defaultAddressForm = (isDefault: boolean): AddressFormValues => ({ label: '', @@ -29,6 +30,7 @@ const defaultAddressForm = (isDefault: boolean): AddressFormValues => ({ }) export function AddressesPage() { + usePageTitle('Адреса доставки') const queryClient = useQueryClient() const [open, setOpen] = useState(false) const [editing, setEditing] = useState(null) diff --git a/client/src/pages/me/ui/sections/MessagesPage.tsx b/client/src/pages/me/ui/sections/MessagesPage.tsx index f225f78..b558264 100644 --- a/client/src/pages/me/ui/sections/MessagesPage.tsx +++ b/client/src/pages/me/ui/sections/MessagesPage.tsx @@ -16,6 +16,7 @@ import { fetchMyOrder, postOrderMessage } from '@/entities/order/api/order-api' import { fetchMyConversations, markOrderMessagesRead } from '@/entities/user/api/messages-api' import { fetchAdminAvatar } from '@/entities/user/api/user-api' import { orderStatusLabelRu } from '@/shared/lib/order-status-labels' +import { usePageTitle } from '@/shared/lib/use-page-title' import { $user } from '@/shared/model/auth' import { ChatMessageBubble } from '@/shared/ui/ChatMessageBubble' import { OrderMessageBody } from '@/shared/ui/OrderMessageBody' @@ -24,6 +25,7 @@ import { RichTextMessageEditor } from '@/shared/ui/RichTextMessageEditor' import { UserAvatar } from '@/shared/ui/UserAvatar' export function MessagesPage() { + usePageTitle('Сообщения') const qc = useQueryClient() const [selectedId, setSelectedId] = useState(null) const [text, setText] = useState('') diff --git a/client/src/pages/me/ui/sections/NotificationsPage.tsx b/client/src/pages/me/ui/sections/NotificationsPage.tsx index a9c5819..a255904 100644 --- a/client/src/pages/me/ui/sections/NotificationsPage.tsx +++ b/client/src/pages/me/ui/sections/NotificationsPage.tsx @@ -13,6 +13,7 @@ import { } from '@/entities/notification/api/notifications-api' import type { UserNotificationSettings } from '@/entities/notification/api/notifications-api' import { isSyntheticEmail } from '@/shared/lib/is-synthetic-email' +import { usePageTitle } from '@/shared/lib/use-page-title' import { $user } from '@/shared/model/auth' function isOrderStatusChangesOn(s: UserNotificationSettings): boolean { @@ -27,6 +28,7 @@ const orderStatusChangesPayload = (on: boolean) => ({ }) export function NotificationsPage() { + usePageTitle('Уведомления') const queryClient = useQueryClient() const [error, setError] = useState(null) const user = useUnit($user) diff --git a/client/src/pages/me/ui/sections/OrdersPage.tsx b/client/src/pages/me/ui/sections/OrdersPage.tsx index fc248af..c6f8351 100644 --- a/client/src/pages/me/ui/sections/OrdersPage.tsx +++ b/client/src/pages/me/ui/sections/OrdersPage.tsx @@ -12,8 +12,10 @@ import { ORDER_STATUSES } from '@/shared/constants/order' import { formatPriceRub } from '@/shared/lib/format-price' import { groupOrdersByStatus } from '@/shared/lib/group-orders-by-status' import { orderStatusLabelRu } from '@/shared/lib/order-status-labels' +import { usePageTitle } from '@/shared/lib/use-page-title' export function OrdersPage() { + usePageTitle('Заказы') const ordersQuery = useQuery({ queryKey: ['me', 'orders'], queryFn: fetchMyOrders, diff --git a/client/src/pages/me/ui/sections/SettingsPage.tsx b/client/src/pages/me/ui/sections/SettingsPage.tsx index 70b5ea5..79c4a2a 100644 --- a/client/src/pages/me/ui/sections/SettingsPage.tsx +++ b/client/src/pages/me/ui/sections/SettingsPage.tsx @@ -4,6 +4,7 @@ import Divider from '@mui/material/Divider' import Stack from '@mui/material/Stack' import Typography from '@mui/material/Typography' import { useUnit } from 'effector-react' +import { usePageTitle } from '@/shared/lib/use-page-title' import { $user } from '@/shared/model/auth' import { AuthMethodsSection } from './AuthMethodsSection' import { AvatarSection } from './AvatarSection' @@ -11,6 +12,7 @@ import { DeleteAccountSection } from './DeleteAccountSection' import { ProfileSection } from './ProfileSection' export function SettingsPage() { + usePageTitle('Настройки') const user = useUnit($user) if (!user) { diff --git a/client/src/pages/privacy-policy/ui/PrivacyPolicyPage.tsx b/client/src/pages/privacy-policy/ui/PrivacyPolicyPage.tsx index 4901f71..c418bab 100644 --- a/client/src/pages/privacy-policy/ui/PrivacyPolicyPage.tsx +++ b/client/src/pages/privacy-policy/ui/PrivacyPolicyPage.tsx @@ -9,6 +9,7 @@ import { STORE_OP_ADDR, STORE_PUBLIC_SITE_URL, } from '@/shared/config' +import { usePageTitle } from '@/shared/lib/use-page-title' const SITE_URL = STORE_PUBLIC_SITE_URL || (typeof window !== 'undefined' ? window.location.origin : '') @@ -90,6 +91,7 @@ const sections = [ ] export function PrivacyPolicyPage() { + usePageTitle('Политика конфиденциальности') return ( { const p = productQuery.data if (!p) return [] diff --git a/client/src/pages/terms/ui/TermsPage.tsx b/client/src/pages/terms/ui/TermsPage.tsx index be64128..2c40c61 100644 --- a/client/src/pages/terms/ui/TermsPage.tsx +++ b/client/src/pages/terms/ui/TermsPage.tsx @@ -10,6 +10,7 @@ import { STORE_OP_INN, STORE_OP_ADDR, } from '@/shared/config' +import { usePageTitle } from '@/shared/lib/use-page-title' const SITE_URL = STORE_PUBLIC_SITE_URL || (typeof window !== 'undefined' ? window.location.origin : '') @@ -147,6 +148,7 @@ const sections = [ ] export function TermsPage() { + usePageTitle('Пользовательское соглашение') return ( { + currentTitle = title ? `${title} — Любимый Креатив` : BASE_TITLE + document.title = currentTitle + }, [title]) +} + +export function usePageTitleReset() { + const location = useLocation() + + useEffect(() => { + document.title = BASE_TITLE + currentTitle = BASE_TITLE + }, [location.pathname]) +} diff --git a/server/prisma/prisma/dev.db b/server/prisma/prisma/dev.db index 72c18a15770083e6197ffa657151ccc335f757e9..58f210993dc6fcda2714094acfcb5472a4f0f7ed 100644 GIT binary patch delta 2882 zcmb7GeQX-l_VJN`O< z$Jx{*Op`hxN-B$)3a#xx>y~QL02iB&i4N8ZY1$ZKt3Z5oP$nj|e`I24{4*4u_v|Ef zV*cQydwt3CJiq68e$U6w#r?zKHbT45Wj8CiQKYtC6bT**khTGG=tpH5_I&EH%l#Ub+i^xUg&HZEsJ z$NgYH_6)3IeFhA&*uWO`d^u%@9fM&8ES{Krq_LD69rtau$Y6_Fm6xm>uQXqMjuwaM zyDZRFi!`=S>j_DW6lWW6jM8xdG|!D21z zUJ@y65tb8$M0ieVyposJ9sI~1@_8R2&=b#lbG)#gWD|aOWf zl4KbnF=mP($&kWAC=`oZx*0e4c+3(ayrs)#Scu7S#WE~e5RH%!=k-iJk;;+h=ck`v z{SP$bxsmU~`JF##iyl{hJlS~FD&cr=wS}^k;cI$oYdo?NQ58{<3|_Z*0X*@F5Rb$y zRaGKJ%#b6ZAWN1Qm-L7Z{|Q@$8qsxeT$NOXmttb57tr)mqX!_8xx>%FS^sCUb>`<) zW`~=*L7=~*>*z)F3`(O(^dRzcA8|Keu7Aqmu9OG!=3cM=pe$T1|Jxryfw=bw^gjAE zx{SVwZ1gA!bDwbUp&)?2&NYH7T;tRVN4gvK$}Z9cf8W{RMhyaejP9Vnq8sQ9bQN7e z%ji66fJF@j+%!u<@Ib7)_Xs(;TFa|n8|Mxa=x^v(=zHkv=rA%s--p;SMk7gB9>F1I z{c%6z-@|Cz@JJ>3nR*-wAS!yPT1h6%S}kD|J?pB&v~S~?LSW|VqHzua1}432o_q~O zbGn$404h{2=J@1HB^6896$o|dra*m~(O8$$fS^pNE zWFBB$Q%8<|VdCh+k57Gmdh*E8$9g(bWb4?@>}(}e1P~i{O(vw5Vwtc23a9UXsrjdQ zCfNDl0?GJB2CB7x03r{Cu6IlR40;iG`U=8jZWiArA36?cBy<2*%8{ZGQ?g1qUsdU~ zQs**R9!pg`K!w?AxT0G*Y8S0_vrE=zk{}haG*zhPEj68N|CYR+Z{F7E(GC-(-a^fr z^Gu-g!7*xd385zlit`Yh2MxpQ4?!rOct5o`OCs&y!NpLW^wWe(V_8gd^Mw_PC&!z= zT%or2|8S}lmJ}7b-T84e@bNh>UhGAR(}F>nAe;UbDoPGFkFQV%w!=FJ96k+hK(fip z31)ik!WY{gQVVD#2N`7`BME0@SF6biyqucfUZp1XJ^SQd;2ch;u7fhaLf`ox2yGvI z=RpU2D_v2V;oTG)AYA`o1L$Ojv+3_L^J&_psbdoa!Lq$RlJXE2pUVm*v7RWIu?f&P z3`uQh$PrKwutqskv?@v}3(K{9D6dz_$wRqHCUf`l#SQl7uXfMnBNXf@;&=4e8rb$h zb?+Q2(96t$FTIRNP~ZLg;dS*vY&6HJ^wXU;3-m#z^RrX*5t2O7UZDB;5j;2wL@MK% zm@UaU$rkd>pRCgRCt(_JL^p-C+1WB+1=6QI;Mz?Dw{4^E#ko5lAOML!C!mP|(#)n! zT}=uO}9<)^6Kg9%^r>`;xoXvt`)$wMj7mB$#t0?qsmMxiV5I!il_G`>A?;@e)zl`sW z??dKantOe>UGH!_`#i#asc{r#dI|;#rpmJzZ)&e{u{Z@ja+t^yxg8*KOS=f O8+#l!_FS^r@&5wXqKVJ| delta 2279 zcmZ`)ZEO=|9KYvYx?Z>4)5(T*FgC<7CbHdKU#<-ySIwr9I2ZvTiyG+5ZGBnWYkOQOS zu2c;MIc{WRUqlV?p#eY7^PO!X=KVQ1Ra6V}g70MP&rch;;K&m51`HZX_)W(eK@ zVu%h*@VPvWKq}-f5Xxx8s2;zZ8Hv_5&A`oveo8j2Yq20)DaRTqm8F++&hT>)jF zJM(GGc0a%g)nMj22zUj43y;A!VHkFx=DA>|b$^-uV?E98ozpQx9VLTeKNo2aGv7L= z74&@ToU*C;)>4&xu}pF~C9|^w&nd}-v;g1~0jJ=_Y{kev*w`2C4MxN31W9r^1j#D# zcBjp)^m`&|uhQa+MJTMG%&yUuHIFw_U96@gwRI6i3&i|MOv+A;)|#u0<--JwbCcZG z$}^SrilY?|mk*=vq{_3yU#944BdnU!IM@oSkog}DH!u6h`5VXsv=Hzr{0<(6Z$TAq zg$`&z5CAj3-{dk)w=N;X7y<9VY4``c2+zU^I1b0MRh0Rj zunkdp_}B_sHkLM_C~7C(-awC%PWSi{hI(2C0EyF5Ml%oYDf+=ZNg)?(0b?NRkT({? z-&W~7&4W|W9dUNY5@=ei=|CjL3#w)fhMnMWS89n$uA*fIZ$t=z)LsN~U{EpFH??it^vurAC%Zb@H*G6nIze{xvY;9W_8}(`LDYI8S~M;M zd|m`tcH*vnc|Wk^ua5%ST(c-F_Zc!(0C)v&!X(n}G1Q(HuyR>lI}g_DNtvlx#h{H| zU&=SwkBjOTT|(Sp?G5zWggPuM{oE+9+7XD{ zkkClLm(fBH1{CTpXG+9LPPSO=1go2Gk#2p@|YD1`l$t=Q)*nm(E9bQ_q1al=^mpFqO$d-O;p# zV362HKV}ujUq4b`EUBD_W-jk&=>LQ8(yJS8{2|DGLUTpOx6pt26Dd;!;PTPblHsDHXK)LqVx0a7S{F@f=2%_cp>$wX^yk|K5!X8@RYbcjx zw(w(^QvN9)3g)%)F3mCM2qb(_WyT;Og9{s^xJ|Y7OA-!U-BnySg88nHv@yehrR^2F z(^f%B7F?kt8Bd}p`SnYqz*K=99J*axb3?^!7n|y9d+2KYjS0GG3+hQi#V%s$l&VIB zP|6od%^vj-9x8ib)T5HaJDBzqM!gh%VP3hf=`x-r(R@6Qt{r*vZTfH7^x$yGb3F3_ zbfUNm4-xz=nK@#5MHJVeB@