Merge branch 'site-fixes'

This commit is contained in:
Kirill
2026-05-24 14:23:09 +05:00
48 changed files with 3255 additions and 237 deletions
@@ -0,0 +1,439 @@
# Приведение Политики конфиденциальности и Пользовательского соглашения в соответствие с проектом
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Убрать из юридических документов упоминания несуществующих функций (аналитика, рекламные рассылки, персонализация, Яндекс.Метрика) и исправить неточности (OAuth, cookie, IP-логирование, дублирование данных оператора).
**Architecture:** 4 задачи: унификация данных оператора в shared/config, правка Политики конфиденциальности, правка Пользовательского соглашения, финальная проверка.
**Tech Stack:** TypeScript, React, MUI — изменения только в статическом JSX-тексте и shared/config.
---
## Файловая структура изменений
| Файл | Что делаем |
| ---------------------------------------------------------- | ------------------------------------------------------------------------------- |
| `client/src/shared/config/index.ts` | Добавляем `STORE_OP_NAME`, `STORE_OP_INN`, `STORE_OP_OGRN`, `STORE_OP_ADDR` |
| `client/src/pages/privacy-policy/ui/PrivacyPolicyPage.tsx` | Импорт из config, правка пунктов 2, 3, 5, 6, 7, добавляем дату |
| `client/src/pages/terms/ui/TermsPage.tsx` | Импорт из config, правка пунктов 1, 3, 7, 8, 9, убираем противоречие 6.1 vs 2.2 |
---
### Task 1: Вынести данные оператора в shared/config
**Files:**
- Modify: `client/src/shared/config/index.ts`
- [ ] **Step 1: Добавить константы оператора в shared/config**
Вставить после строки 17 (перед `export const VK_URL`):
```ts
export const STORE_OP_NAME =
"Индивидуальный предприниматель Новоселова Наталия Владимировна";
export const STORE_OP_INN = "402900832341";
export const STORE_OP_OGRN = "305402922700051";
export const STORE_OP_ADDR = "248000, Россия, г. Калуга, ул. Никитина, д. 12А";
```
- [ ] **Step 2: Проверить синтаксис**
```bash
cd client && npx tsc -b --noEmit
```
Expected: no errors.
---
### Task 2: Исправить Политику конфиденциальности
**Files:**
- Modify: `client/src/pages/privacy-policy/ui/PrivacyPolicyPage.tsx`
- [ ] **Step 1: Заменить импорт и локальные константы**
Заменить строки 4-10 (импорт STORE_EMAIL + локальные константы).
Было:
```ts
import { STORE_EMAIL } from "@/shared/config";
const OP_NAME =
"Индивидуальный предприниматель Новоселова Наталия Владимировна";
const OP_INN = "402900832341";
const OP_OGRN = "305402922700051";
const OP_ADDR = "248000, Россия, г. Калуга, ул. Никитина, д. 12А";
const SITE_URL = window.location.origin;
```
Стало:
```ts
import {
STORE_EMAIL,
STORE_OP_NAME,
STORE_OP_INN,
STORE_OP_OGRN,
STORE_OP_ADDR,
STORE_PUBLIC_SITE_URL,
} from "@/shared/config";
const SITE_URL =
STORE_PUBLIC_SITE_URL ||
(typeof window !== "undefined" ? window.location.origin : "");
```
И заменить `OP_NAME``STORE_OP_NAME`, `OP_INN``STORE_OP_INN`, `OP_OGRN``STORE_OP_OGRN`, `OP_ADDR``STORE_OP_ADDR` во всём файле (replaceAll).
- [ ] **Step 2: Исправить раздел 2 — актуальный список собираемых данных**
Заменить `items` в секции 2.
Было:
```ts
items: [
'2.1. Оператор обрабатывает следующие персональные данные Пользователей:',
'— фамилия, имя, отчество;',
'— адрес электронной почты;',
'— номер телефона;',
'— данные файлов cookie;',
'— данные о действиях на сайте (аналитика);',
'— адрес доставки и геолокационные координаты.',
],
```
Стало:
```ts
items: [
'2.1. Оператор обрабатывает следующие персональные данные Пользователей:',
'— адрес электронной почты;',
'— имя (отображаемое имя, может быть указано Пользователем добровольно);',
'— номер телефона (указывается Пользователем добровольно при оформлении доставки);',
'— адрес доставки и геолокационные координаты (указываются Пользователем при оформлении заказа);',
'— аутентификационные данные (сессионные cookie для поддержания входа в Личный кабинет).',
],
```
- [ ] **Step 3: Исправить раздел 3 — убрать несуществующую персонализацию**
Было:
```ts
items: [
'3.1. Оператор обрабатывает персональные данные в следующих целях:',
'— идентификация Пользователя;',
'— оказание услуг / продажа товаров;',
'— направление уведомлений и информационных сообщений;',
'— улучшение качества работы сайта;',
'— построение персонализированных предложений и рекомендаций.',
],
```
Стало:
```ts
items: [
'3.1. Оператор обрабатывает персональные данные в следующих целях:',
'— идентификация и аутентификация Пользователя;',
'— оказание услуг / продажа товаров и оформление доставки;',
'— направление транзакционных уведомлений о статусе заказов и информационных сообщений;',
'— улучшение качества работы сайта.',
],
```
- [ ] **Step 4: Исправить раздел 5 — убрать неавтоматизированную обработку и нереалистичный срок**
Было:
```ts
items: [
'5.1. Обработка осуществляется путём сбора, записи, систематизации, накопления, хранения, уточнения, извлечения, использования, передачи, обезличивания, блокирования, удаления и уничтожения персональных данных.',
'5.2. Обработка осуществляется автоматизированным и неавтоматизированным способами.',
'5.3. Срок хранения персональных данных: не более 7 лет с момента последнего обращения Пользователя либо до момента отзыва согласия на обработку.',
],
```
Стало:
```ts
items: [
'5.1. Обработка осуществляется путём сбора, записи, систематизации, накопления, хранения, уточнения, извлечения, использования, передачи, блокирования, удаления и уничтожения персональных данных.',
'5.2. Обработка осуществляется автоматизированным способом с использованием программных средств Сайта.',
'5.3. Срок хранения персональных данных: до достижения целей обработки либо до момента отзыва Пользователем согласия на обработку.',
],
```
- [ ] **Step 5: Исправить раздел 6 — Яндекс.Метрика → ЮKassa**
Было:
```ts
items: [
'6.1. Оператор может передать персональные данные третьим лицам в следующих случаях:',
'— с согласия субъекта;',
'— по требованию законодательства РФ;',
'— для выполнения договорных обязательств (перечень третьих лиц): службы доставки, платёжные агрегаторы, сервисы аналитики (Яндекс.Метрика).',
],
```
Стало:
```ts
items: [
'6.1. Оператор может передать персональные данные третьим лицам в следующих случаях:',
'— с согласия субъекта;',
'— по требованию законодательства РФ;',
'— для выполнения договорных обязательств (перечень третьих лиц): службы доставки, платёжный сервис (ЮKassa).',
],
```
- [ ] **Step 6: Добавить дату обновления**
Заменить текст подзаголовка (строка 99):
```
Политика в отношении обработки персональных данных.
```
на:
```
Последнее обновление: 23 мая 2026 г.
```
- [ ] **Step 7: Проверить линтер**
```bash
cd client && npm run lint
```
Expected: 0 новых ошибок.
---
### Task 3: Исправить Пользовательское соглашение
**Files:**
- Modify: `client/src/pages/terms/ui/TermsPage.tsx`
- [ ] **Step 1: Заменить локальные константы на импорт из config**
Заменить строки 4-11.
Было:
```ts
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 : "");
const OP_NAME =
"Индивидуальный предприниматель Новоселова Наталия Владимировна";
const OP_INN = "402900832341";
const OP_OGRN = "305402922700051";
const OP_ADDR = "248000, Россия, г. Калуга, ул. Никитина, д. 12А";
```
Стало:
```ts
import {
STORE_EMAIL,
STORE_PHONE,
STORE_PUBLIC_SITE_URL,
STORE_OP_NAME,
STORE_OP_INN,
STORE_OP_OGRN,
STORE_OP_ADDR,
} from "@/shared/config";
const SITE_URL =
STORE_PUBLIC_SITE_URL ||
(typeof window !== "undefined" ? window.location.origin : "");
```
Заменить `OP_NAME``STORE_OP_NAME`, `OP_INN``STORE_OP_INN`, `OP_OGRN``STORE_OP_OGRN`, `OP_ADDR``STORE_OP_ADDR` во всём файле (replaceAll).
- [ ] **Step 2: Дополнить раздел 1 — упомянуть OAuth и вход по коду**
В секции 1, в определении «Аутентификационные данные». Найти:
```
'— Аутентификационные данные Пользователя — адрес электронной почты Пользователя и пароль (код доступа), которые в совокупности признаются простой электронной подписью Пользователя.',
```
Заменить на:
```
'— Аутентификационные данные Пользователя — адрес электронной почты и пароль (код доступа), либо данные, полученные через сервисы авторизации третьих лиц (VK ID, Яндекс ID), либо одноразовый код, направляемый на электронную почту. Совокупность аутентификационных данных признаётся простой электронной подписью Пользователя.',
```
- [ ] **Step 3: Убрать «рекламные» сообщения из п. 3.7**
Найти:
```
'3.7. При регистрации Пользователь даёт согласие на получение информационных и рекламных сообщений от Администратора на указанный адрес электронной почты.',
```
Заменить на:
```
'3.7. При регистрации Пользователь даёт согласие на получение транзакционных уведомлений (статус заказа, сообщения в чате заказа, статус оплаты) на указанный адрес электронной почты.',
```
- [ ] **Step 4: Исправить противоречие 6.1 vs 2.2 («гарантирует» vs «as is»)**
Найти в секции 6:
```
'6.1. Администратор гарантирует достоверность и полноту только той информации, которую он разместил на Сайте самостоятельно.',
```
Заменить на:
```
'6.1. Администратор прилагает разумные усилия для обеспечения достоверности и полноты информации, размещённой на Сайте, однако не даёт явных гарантий точности такой информации.',
```
- [ ] **Step 5: Исправить раздел 7 — указать реальных третьих лиц (ЮKassa, OSM вместо рекламы/аналитики)**
Заменить всю секцию 7.
Было:
```ts
{
title: '7. Доступ к ресурсам третьих лиц',
items: [
'7.1. Доступ Пользователя к Сайту может вызывать обращение к интернет-ресурсам третьих лиц (реклама, сбор статистики).',
'7.2. Владельцы таких ресурсов имеют техническую возможность собирать информацию о Пользователях и самостоятельно определяют условия её использования.',
'7.3. При переходе на сторонние ресурсы Пользователи самостоятельно определяют пределы использования своей информации согласно правилам соответствующих ресурсов.',
],
},
```
Стало:
```ts
{
title: '7. Доступ к ресурсам третьих лиц',
items: [
'7.1. Для обеспечения функциональности Сайта используются сервисы третьих лиц: платёжный сервис ЮKassa (для обработки онлайн-платежей).
'7.2. Владельцы указанных ресурсов имеют собственную политику конфиденциальности и самостоятельно определяют условия обработки получаемой информации.',
'7.3. При переходе на сторонние ресурсы Пользователи самостоятельно определяют пределы использования своей информации согласно правилам соответствующих ресурсов.',
],
},
```
- [ ] **Step 6: Исправить раздел 8 — Cookie только для сессии, не для аналитики**
Заменить всю секцию 8.
Было:
```ts
{
title: '8. Информация, хранящаяся на стороне браузера',
items: [
'8.1. Администратор использует cookie-файлы для определения уникального идентификатора доступа Пользователя к Сайту.',
'8.2. Цели использования cookie:',
'— поддержка функциональности Сайта, требующей использования cookie;',
'— измерение аудитории Сайта;',
'— определение статистических предпочтений Пользователей;',
'— исследование корреляции статистических данных.',
'8.3. Пользователь может запретить использование cookie в настройках браузера, однако это может привести к частичной или полной потере функциональности Сайта.',
],
},
```
Стало:
```ts
{
title: '8. Информация, хранящаяся на стороне браузера',
items: [
'8.1. Администратор использует сессионные cookie-файлы исключительно для поддержания аутентификации Пользователя в Личном кабинете.',
'8.2. Сайт не использует cookie для сбора статистики, отслеживания действий Пользователя или показа рекламы.',
'8.3. Пользователь может запретить использование cookie в настройках браузера, однако это приведёт к невозможности входа в Личный кабинет и использования функций, требующих аутентификации.',
],
},
```
- [ ] **Step 7: Исправить раздел 9.3–9.4 — актуальный перечень данных и целей**
Заменить строки 9.3 и 9.4 в секции 9.
Было:
```ts
'9.3. Администратор обрабатывает следующие персональные данные: Ф. И. О., адрес электронной почты, номер телефона, IP-адрес, тип браузера, данные о действиях на Сайте.',
'9.4. Цели обработки персональных данных: обеспечение функционирования Сайта, оказание информационной поддержки, предоставление персонализированных сервисов, направление информационных сообщений.',
```
Стало:
```ts
'9.3. Администратор обрабатывает следующие персональные данные: адрес электронной почты, имя (при добровольном указании), номер телефона (при оформлении доставки), адрес доставки.',
'9.4. Цели обработки персональных данных: обеспечение функционирования Сайта, аутентификация Пользователя, оформление и доставка заказов, направление транзакционных уведомлений.',
```
- [ ] **Step 8: Проверить линтер**
```bash
cd client && npm run lint
```
Expected: 0 новых ошибок.
---
### Task 4: Финальная проверка
**Files:** No modifications, verification only.
- [ ] **Step 1: TypeScript check**
```bash
cd client && npx tsc -b --noEmit
```
Expected: no errors.
- [ ] **Step 2: Lint**
```bash
cd client && npm run lint
```
Expected: 0 errors (warnings OK).
- [ ] **Step 3: Сборка**
```bash
cd client && npm run build
```
Expected: успешная сборка.
- [ ] **Step 4: Format check**
```bash
cd client && npm run format:check
```
Expected: все файлы отформатированы (или отформатировать через `npm run format`).
@@ -0,0 +1,64 @@
# Юридические документы: приведение к реальности + удаление аккаунта + cookie-баннер
## Данные оператора
- **Имя:** Комарова Лариса Николаевна (самозанятый)
- **ИНН:** 402900832341 (тестовый)
- **Адрес:** 34, ул. Мира, кв. 34, Лысьва, Пермский край, 618909
- **Сайт:** https://любимыйкреатив.рф
- **ОГРН:** отсутствует (самозанятый)
## Задачи
### 1. Shared config — обновить данные оператора
Файл: `client/src/shared/config/index.ts`
- `STORE_OP_NAME`, `STORE_OP_TYPE`, `STORE_OP_INN` (тестовый), `STORE_OP_ADDR`
- Убрать `STORE_OP_OGRN`
- `STORE_PUBLIC_SITE_URL`: https://любимыйкреатив.рф
### 2. Политика конфиденциальности
Файл: `client/src/pages/privacy-policy/ui/PrivacyPolicyPage.tsx`
- Импорт из config, убрать локальные константы
- Оператор: самозанятый вместо ИП, без ОГРН
- Раздел 2: только email, имя, телефон (при доставке), адрес, сессионные cookie (без ФИО, аналитики)
- Раздел 3: без персонализации
- Раздел 5: только автообработка, срок до достижения целей
- Раздел 6: Яндекс.Метрика → ЮKassa
- Раздел 7: добавить право на самоудаление (п. 7.3)
- Добавить дату обновления
- Перенести cookie-раздел из Соглашения в Политику
### 3. Пользовательское соглашение
Файл: `client/src/pages/terms/ui/TermsPage.tsx`
- Импорт из config, убрать локальные константы
- Оператор: самозанятый, без ОГРН
- Раздел 1: упомянуть OAuth и вход по коду
- П. 3.7: «рекламные» → «транзакционные»
- П. 6.1: убрать противоречие с «as is»
- Раздел 7: реальные третьи лица (ЮKassa, OSM)
- Раздел 8: cookie только для сессии, не для аналитики
- П. 9.3-9.4: без IP/браузера/персонализации, только реальные данные
### 4. Cookie-баннер
Новый компонент: `client/src/shared/ui/CookieConsentBanner.tsx`
- Снизу, фиксированный, localStorage
- Текст о cookie и ссылка на Политику
- Кнопка «Понятно»
- Рендер в MainLayout перед футером
### 5. Текст согласия на формах входа/регистрации
- AuthPasswordForm, AuthCodeForm
- Под кнопкой отправки: «Нажимая «Продолжить», вы принимаете пользовательское соглашение и политику конфиденциальности»
- Ссылки на /terms и /privacy
### 6. Удаление аккаунта
**Сервер:** `DELETE /api/me` в `server/src/routes/auth.js`
- Проверка активных заказов (не DONE, не CANCELLED)
- Если нет активных — каскадное удаление
- Если есть — 400 с перечнем заказов
**Клиент:** секция в SettingsPage
- Кнопка «Удалить аккаунт» (outlined, error)
- Tooltip при активных заказах
- Диалог подтверждения
- После удаления — редирект на /
@@ -0,0 +1 @@
5700
@@ -0,0 +1 @@
{"reason":"idle timeout","timestamp":1779612416287}
@@ -0,0 +1 @@
7319
+8
View File
@@ -67,6 +67,14 @@ cd client && npm run build # full typecheck + build
- Yandex callback: `{SERVER_PUBLIC_URL}/api/auth/oauth/yandex/callback`
- Required env vars: `VK_CLIENT_ID`, `VK_CLIENT_SECRET`, `YANDEX_CLIENT_ID`, `YANDEX_CLIENT_SECRET`, `SERVER_PUBLIC_URL`, `CLIENT_PUBLIC_URL`
## Infrastructure (deployment)
- **VPS** runs Nginx Proxy Manager (NPM), connected via Netbird peer-to-peer VPN to the dev machine
- **Local dev machine** runs the project (server + client), also a Netbird peer
- **Traffic flow**: Browser → Domain (A record → VPS IP) → NPM → Netbird tunnel → Local dev machine (`server:3333`)
- NPM manages SSL, domains, and proxy hosts
- `trustProxy: true` on Fastify — `request.ip` works correctly through NPM/Netbird chain
## Notable quirks
- `.env` is gitignored. Copy `.env.example` to `.env` for local dev.
+2
View File
@@ -10,6 +10,7 @@ import { Link as RouterLink } from 'react-router-dom'
import { AppHeader } from '@/app/layout/AppHeader'
import vkLogoSrc from '@/shared/assets/vk-logo.svg'
import { STORE_EMAIL, STORE_NAME, STORE_PHONE, VK_URL } from '@/shared/config'
import { CookieConsentBanner } from '@/shared/ui/CookieConsentBanner'
import { ScrollOnNavigate } from '@/shared/ui/ScrollOnNavigate'
import { ScrollToTop } from '@/shared/ui/ScrollToTop'
@@ -118,6 +119,7 @@ export function MainLayout({ children }: PropsWithChildren) {
</Box>
</Container>
</Box>
<CookieConsentBanner />
</Box>
)
}
@@ -128,6 +128,7 @@ export function AddressMapPicker(props: {
const lat = Number(r.lat)
const lng = Number(r.lon)
if (!Number.isFinite(lat) || !Number.isFinite(lng)) return
mapRef.current?.flyTo({ center: [lng, lat], zoom: 13, duration: 800 })
void pick({ lat, lng })
}}
>
@@ -1,10 +1,13 @@
import Button from '@mui/material/Button'
import InputAdornment from '@mui/material/InputAdornment'
import Link from '@mui/material/Link'
import Stack from '@mui/material/Stack'
import TextField from '@mui/material/TextField'
import Typography from '@mui/material/Typography'
import { useMutation } from '@tanstack/react-query'
import { Mail } from 'lucide-react'
import { useForm } from 'react-hook-form'
import { Link as RouterLink } from 'react-router-dom'
import { apiClient } from '@/shared/api/client'
import { getApiErrorMessage } from '@/shared/lib/get-api-error-message'
import { tokenSet } from '@/shared/model/auth'
@@ -95,6 +98,18 @@ export function AuthCodeForm({ onSuccess }: Props) {
sx={{ display: 'none' }}
/>
)}
<Typography variant="caption" color="text.secondary" sx={{ textAlign: 'center' }}>
Нажимая «Войти», вы принимаете{' '}
<Link component={RouterLink} to="/terms" underline="hover">
пользовательское соглашение
</Link>{' '}
и{' '}
<Link component={RouterLink} to="/privacy" underline="hover">
политику конфиденциальности
</Link>
.
</Typography>
</Stack>
)
}
@@ -1,10 +1,13 @@
import Button from '@mui/material/Button'
import InputAdornment from '@mui/material/InputAdornment'
import Link from '@mui/material/Link'
import Stack from '@mui/material/Stack'
import TextField from '@mui/material/TextField'
import Typography from '@mui/material/Typography'
import { useMutation } from '@tanstack/react-query'
import { Lock, Mail } from 'lucide-react'
import { useForm } from 'react-hook-form'
import { Link as RouterLink } from 'react-router-dom'
import { apiClient } from '@/shared/api/client'
import { getApiErrorMessage } from '@/shared/lib/get-api-error-message'
import { tokenSet } from '@/shared/model/auth'
@@ -185,6 +188,18 @@ export function AuthPasswordForm({ isRegister, onRegisterChange, onSuccess }: Pr
sx={{ display: 'none' }}
/>
)}
<Typography variant="caption" color="text.secondary" sx={{ textAlign: 'center' }}>
Нажимая «{isRegister ? 'Зарегистрироваться' : 'Войти'}», вы принимаете{' '}
<Link component={RouterLink} to="/terms" underline="hover">
пользовательское соглашение
</Link>{' '}
и{' '}
<Link component={RouterLink} to="/privacy" underline="hover">
политику конфиденциальности
</Link>
.
</Typography>
</Stack>
)
}
@@ -19,12 +19,7 @@ function ReviewItem({ rv }: { rv: PublicProductReviewItem }) {
<Paper variant="outlined" sx={{ p: 1.5, borderRadius: 2 }}>
<Stack spacing={0.75}>
<Stack direction="row" spacing={1.5} sx={{ alignItems: 'center' }}>
<UserAvatar
userId={rv.authorId}
avatarUrl={rv.authorAvatar}
avatarStyle={rv.authorAvatarStyle}
size={32}
/>
<UserAvatar userId={rv.authorId} avatarUrl={rv.authorAvatar} avatarStyle={rv.authorAvatarStyle} size={32} />
<Box sx={{ flexGrow: 1 }}>
<Typography sx={{ fontWeight: 700 }}>{rv.authorDisplay}</Typography>
</Box>
+4 -6
View File
@@ -35,12 +35,8 @@ export function AboutPage() {
<Stack spacing={3}>
<Paper variant="outlined" sx={{ p: 2, borderRadius: 2 }}>
<Typography variant="h6" gutterBottom>
Контакты и самовывоз
Контакты
</Typography>
<Typography sx={{ mb: 1 }}>
Забрать заказ можно по адресу самовывоза (координаты указаны на карте ниже):
</Typography>
<Typography sx={{ whiteSpace: 'pre-wrap', fontWeight: 600 }}>{PICKUP_ADDRESS_FULL}</Typography>
<Typography variant="body2" sx={{ mt: 1 }}>
Email:{' '}
<Link href={`mailto:${STORE_EMAIL}`} underline="hover">
@@ -58,6 +54,8 @@ export function AboutPage() {
ВКонтакте
</Link>
</Typography>
<Typography sx={{ mb: 1, mt: 2 }}>Забрать заказ можно по адресу самовывоза:</Typography>
<Typography sx={{ whiteSpace: 'pre-wrap', fontWeight: 600 }}>{PICKUP_ADDRESS_FULL}</Typography>
<Typography color="text.secondary" variant="body2" sx={{ mt: 1 }}>
Перед визитом мы свяжемся с вами и согласуем время чтобы заказ точно был готов к выдаче.
</Typography>
@@ -75,7 +73,7 @@ export function AboutPage() {
>
<Map
mapLib={maplibregl}
initialViewState={{ latitude: lat, longitude: lng, zoom: 16 }}
initialViewState={{ latitude: lat, longitude: lng, zoom: 15 }}
style={{ width: '100%', height: 380 }}
mapStyle={rasterStyle}
scrollZoom={false}
+55 -14
View File
@@ -1,27 +1,68 @@
import Box from '@mui/material/Box'
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 { DeliverySection } from './sections/DeliverySection'
import { HowToOrderSection } from './sections/HowToOrderSection'
import { OrderStatusesSection } from './sections/OrderStatusesSection'
import { PaymentSection } from './sections/PaymentSection'
import { ReturnsSection } from './sections/ReturnsSection'
export function InfoPage() {
return (
<Box>
<Typography variant="h4" gutterBottom>
Информация для покупателей
</Typography>
<Typography color="text.secondary" sx={{ mb: 3 }}>
Как оформить заказ, как проходит доставка, оплата и другие важные детали.
</Typography>
<Container maxWidth="lg" sx={{ py: { xs: 4 } }}>
{/* Hero */}
<Box sx={{ mb: 8 }}>
<Typography
variant="h3"
sx={{
fontWeight: 700,
fontSize: { xs: '2rem', md: '2.75rem' },
letterSpacing: '-0.035em',
lineHeight: 1.1,
mb: 2,
textWrap: 'balance',
}}
>
Информация для покупателей
</Typography>
<Typography
sx={{
fontSize: '1rem',
color: 'text.secondary',
lineHeight: 1.7,
maxWidth: '58ch',
}}
>
Как оформить заказ, как проходит доставка, оплата и другие важные детали.
</Typography>
</Box>
<Stack spacing={3}>
<HowToOrderSection />
<DeliverySection />
<PaymentSection />
<ReturnsSection />
</Stack>
</Box>
{/* Main content grid */}
<Box
sx={{
display: 'grid',
gridTemplateColumns: { xs: '1fr', lg: '1fr 1fr' },
gap: { xs: 6, md: 8 },
}}
>
{/* Left column */}
<Stack spacing={6}>
<HowToOrderSection />
<Divider />
<DeliverySection />
</Stack>
{/* Right column */}
<Stack spacing={6}>
<PaymentSection />
<Divider />
<OrderStatusesSection />
<Divider />
<ReturnsSection />
</Stack>
</Box>
</Container>
)
}
@@ -1,52 +1,172 @@
import Grid from '@mui/material/Grid'
import Paper from '@mui/material/Paper'
import Box from '@mui/material/Box'
import Link from '@mui/material/Link'
import Stack from '@mui/material/Stack'
import Typography from '@mui/material/Typography'
import { Package, Store } from 'lucide-react'
import { Link as RouterLink } from 'react-router-dom'
import { DELIVERY_CARRIER_OPTIONS } from '@/shared/constants/delivery-carrier'
import { PICKUP_ADDRESS_FULL } from '@/shared/constants/pickup-point'
const deliveries = [
{
title: 'Самовывоз',
icon: <Store size={28} />,
lines: ['Бесплатно.', PICKUP_ADDRESS_FULL, 'Перед визитом согласуем время — чтобы заказ точно был готов к выдаче.'],
},
{
title: 'Почта / Службы доставки',
icon: <Package size={28} />,
lines: [
'Отправка в другие города.',
'Каждому заказу присваивается трек-номер для отслеживания.',
'Стоимость рассчитывается по тарифу перевозчика при оформлении.',
],
},
]
export function DeliverySection() {
return (
<Paper variant="outlined" sx={{ p: 3, borderRadius: 2 }}>
<Typography variant="h5" gutterBottom>
<Box>
<Typography
variant="h5"
sx={{
fontWeight: 700,
letterSpacing: '-0.03em',
lineHeight: 1.15,
mb: 3,
}}
>
Доставка
</Typography>
<Grid container spacing={2}>
{deliveries.map((d) => (
<Grid key={d.title} size={{ xs: 12, sm: 6, md: 4 }}>
<Paper variant="outlined" sx={{ p: 2, borderRadius: 2, height: '100%' }}>
<Stack spacing={1.5}>
<Stack direction="row" spacing={1} sx={{ alignItems: 'center' }}>
{d.icon}
<Typography variant="h6">{d.title}</Typography>
</Stack>
{d.lines.map((line, i) => (
<Typography key={i} variant="body2" color="text.secondary">
{line}
</Typography>
))}
</Stack>
</Paper>
</Grid>
))}
</Grid>
</Paper>
<Stack
direction={{ xs: 'column', sm: 'row' }}
spacing={3}
sx={{
p: 3,
borderRadius: '14px',
border: '1px solid',
borderColor: 'divider',
}}
>
{/* Pickup */}
<Box sx={{ flex: 1 }}>
<Stack spacing={2}>
<Stack direction="row" spacing={1.5} sx={{ alignItems: 'center' }}>
<Box
sx={{
width: 40,
height: 40,
borderRadius: '10px',
backgroundColor: 'action.hover',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: 'text.secondary',
}}
>
<Store size={20} />
</Box>
<Typography
sx={{
fontWeight: 600,
fontSize: '0.95rem',
letterSpacing: '-0.01em',
}}
>
Самовывоз
</Typography>
</Stack>
<Typography
sx={{
fontSize: '0.8rem',
color: 'text.secondary',
lineHeight: 1.65,
}}
>
Бесплатно.
</Typography>
<Typography
sx={{
fontSize: '0.8rem',
color: 'text.secondary',
lineHeight: 1.65,
}}
>
{PICKUP_ADDRESS_FULL}
</Typography>
<Typography
sx={{
fontSize: '0.8rem',
color: 'text.secondary',
lineHeight: 1.65,
}}
>
Перед визитом согласуем время чтобы заказ точно был готов к выдаче.
</Typography>
<Link component={RouterLink} to="/about" sx={{ fontSize: '0.8rem', fontWeight: 500, mt: 0.5 }}>
Посмотреть на карте
</Link>
</Stack>
</Box>
<Box
sx={{
width: '1px',
flexShrink: 0,
display: { xs: 'none', sm: 'block' },
borderLeft: '1px solid',
borderColor: 'divider',
}}
/>
{/* Delivery */}
<Box sx={{ flex: 1 }}>
<Stack spacing={2}>
<Stack direction="row" spacing={1.5} sx={{ alignItems: 'center' }}>
<Box
sx={{
width: 40,
height: 40,
borderRadius: '10px',
backgroundColor: 'action.hover',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: 'text.secondary',
}}
>
<Package size={20} />
</Box>
<Typography
sx={{
fontWeight: 600,
fontSize: '0.95rem',
letterSpacing: '-0.01em',
}}
>
Доставка по России
</Typography>
</Stack>
<Typography
sx={{
fontSize: '0.8rem',
color: 'text.secondary',
lineHeight: 1.65,
}}
>
Доступные службы доставки:
</Typography>
<Stack component="ul" spacing={0.75} sx={{ pl: 2, m: 0, listStyle: 'disc' }}>
{DELIVERY_CARRIER_OPTIONS.map((c) => (
<Typography
key={c.code}
component="li"
sx={{
fontSize: '0.8rem',
color: 'text.secondary',
lineHeight: 1.5,
}}
>
{c.label}
</Typography>
))}
</Stack>
<Typography
sx={{
fontSize: '0.8rem',
color: 'text.secondary',
lineHeight: 1.65,
}}
>
Стоимость рассчитывается по тарифу перевозчика. Админ скорректирует цену после оформления заказа.
</Typography>
</Stack>
</Box>
</Stack>
</Box>
)
}
@@ -1,55 +1,233 @@
import Paper from '@mui/material/Paper'
import Step from '@mui/material/Step'
import StepContent from '@mui/material/StepContent'
import StepLabel from '@mui/material/StepLabel'
import Stepper from '@mui/material/Stepper'
import { useState } from 'react'
import Box from '@mui/material/Box'
import Link from '@mui/material/Link'
import Stack from '@mui/material/Stack'
import Tab from '@mui/material/Tab'
import Tabs from '@mui/material/Tabs'
import Typography from '@mui/material/Typography'
import { CheckCircle, ClipboardList, Mail, ShoppingCart, Truck } from 'lucide-react'
import {
CheckCircle,
ClipboardList,
Clock,
CreditCard,
MapPin,
PackageOpen,
ShoppingCart,
Star,
Store,
Truck,
} from 'lucide-react'
import { Link as RouterLink } from 'react-router-dom'
import { PICKUP_ADDRESS_SHORT } from '@/shared/constants/pickup-point'
const steps = [
const commonSteps = [
{
label: 'Выберите товары',
icon: <ShoppingCart size={20} />,
icon: <ShoppingCart size={18} />,
text: 'Найдите нужные изделия в каталоге и добавьте их в корзину. Вы можете выбрать несколько товаров от разных мастеров — все они соберутся в одном заказе.',
},
{
label: 'Проверьте корзину',
icon: <ClipboardList size={20} />,
icon: <ClipboardList size={18} />,
text: 'Перейдите в корзину и проверьте состав заказа: названия товаров, количество и итоговую сумму. Здесь же можно изменить количество или удалить позиции.',
},
{
label: 'Укажите контакты и адрес',
icon: <Mail size={20} />,
text: 'Заполните имя, телефон и email для связи. Укажите адрес доставки — город, улицу, дом и квартиру. Эти данные нужны для расчёта стоимости и сроков.',
},
{
label: 'Выберите доставку и оплату',
icon: <Truck size={20} />,
text: 'Выберите способ доставки: самовывоз, курьер или почта/СДЭК. Затем укажите способ оплаты: картой онлайн или при получении.',
},
{
label: 'Подтвердите заказ',
icon: <CheckCircle size={20} />,
text: 'Проверьте все данные ещё раз и нажмите «Оформить заказ». После этого мастер получит уведомление и начнёт подготовку вашего изделия.',
},
]
export function HowToOrderSection() {
const deliverySteps = [
{
label: 'Укажите адрес доставки и получателя',
icon: <MapPin size={18} />,
text: 'Заполните имя, телефон, и адрес доставки. Если у вас уже есть сохранённые адреса — выберите из списка или добавьте новый в личном кабинете.',
},
{
label: 'Выберите способ доставки',
icon: <Truck size={18} />,
text: 'Доступны: Почта России, Озон ПВЗ, Яндекс ПВЗ, 5Post, WB ПВЗ. После оформления админ рассчитает точную стоимость доставки и скорректирует цену заказа.',
},
{
label: 'Оплатите заказ',
icon: <CreditCard size={18} />,
text: 'Онлайн-оплата через ЮKassa — банковские карты и СБП. Перенаправление на защищённую платёжную страницу.',
},
{
label: 'Получите заказ',
icon: <PackageOpen size={18} />,
text: 'После отправки вы получите трек-номер для отслеживания. Следите за статусом заказа в личном кабинете.',
},
]
const pickupSteps = [
{
label: 'Подтвердите заказ',
icon: <CheckCircle size={18} />,
text: 'Выберите способ оплаты: онлайн через ЮKassa (карты, СБП) или при получении (наличные / карта).',
},
{
label: 'Согласуйте время получения',
icon: <Clock size={18} />,
text: 'Админ свяжется с вами, чтобы договориться об удобном времени выдачи заказа.',
},
{
label: 'Получите заказ',
icon: <Store size={18} />,
text: `Адрес: ${PICKUP_ADDRESS_SHORT}. Перед визитом согласуем время, чтобы заказ точно был готов.`,
},
]
function StepRow({ step, isLast }: { step: (typeof commonSteps)[0]; isLast: boolean }) {
return (
<Paper variant="outlined" sx={{ p: 3, borderRadius: 2 }}>
<Typography variant="h5" gutterBottom>
Как оформить заказ
</Typography>
<Stepper orientation="vertical" activeStep={-1}>
{steps.map((step) => (
<Step key={step.label} completed={false}>
<StepLabel slots={{ stepIcon: () => step.icon }}>{step.label}</StepLabel>
<StepContent>
<Typography color="text.secondary">{step.text}</Typography>
</StepContent>
</Step>
))}
</Stepper>
</Paper>
<Box sx={{ display: 'flex', gap: 2.5, pb: isLast ? 0 : 2.5, position: 'relative' }}>
<Stack spacing={0} sx={{ alignItems: 'center', flexShrink: 0, width: 20, pt: 0.25 }}>
<Box
sx={{
width: 20,
height: 20,
borderRadius: '6px',
backgroundColor: 'action.hover',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: 'text.secondary',
flexShrink: 0,
zIndex: 1,
}}
>
{step.icon}
</Box>
{!isLast && (
<Box
sx={{
width: 1.5,
flex: 1,
minHeight: 16,
backgroundColor: 'divider',
borderRadius: 1,
mt: 0.5,
}}
/>
)}
</Stack>
<Box sx={{ flex: 1, pt: 0.1 }}>
<Typography
sx={{
fontWeight: 600,
fontSize: '0.875rem',
letterSpacing: '-0.01em',
mb: 0.5,
}}
>
{step.label}
</Typography>
<Typography
sx={{
fontSize: '0.8rem',
color: 'text.secondary',
lineHeight: 1.65,
}}
>
{step.text}
</Typography>
</Box>
</Box>
)
}
function BranchStepper({ steps }: { steps: typeof deliverySteps }) {
return (
<Stack spacing={0} sx={{ mt: 1 }}>
{steps.map((step, i) => (
<StepRow key={step.label} step={step} isLast={i === steps.length - 1} />
))}
</Stack>
)
}
export function HowToOrderSection() {
const [tab, setTab] = useState(0)
return (
<Box>
<Typography
variant="h5"
sx={{
fontWeight: 700,
letterSpacing: '-0.03em',
lineHeight: 1.15,
mb: 3,
}}
>
Как оформить заказ
</Typography>
<BranchStepper steps={commonSteps} />
<Box sx={{ mt: 2.5, mb: 1 }}>
<Tabs
value={tab}
onChange={(_, v) => setTab(v)}
variant="fullWidth"
sx={{
minHeight: 36,
'& .MuiTab-root': {
minHeight: 36,
fontSize: '0.8rem',
fontWeight: 600,
letterSpacing: '-0.01em',
},
'& .MuiTabs-indicator': {
height: 2,
borderRadius: 1,
},
}}
>
<Tab label="Доставка" />
<Tab label="Самовывоз" />
</Tabs>
</Box>
{tab === 0 && <BranchStepper steps={deliverySteps} />}
{tab === 1 && (
<Box>
<BranchStepper steps={pickupSteps} />
<Typography variant="body2" sx={{ mt: 1.5 }}>
<Link component={RouterLink} to="/about" sx={{ fontSize: '0.8rem', fontWeight: 500 }}>
Посмотреть на карте
</Link>
</Typography>
</Box>
)}
<Box
sx={{
mt: 3,
display: 'flex',
alignItems: 'flex-start',
gap: 1.5,
p: 2,
borderRadius: '10px',
backgroundColor: 'action.hover',
}}
>
<Box
sx={{
color: 'text.secondary',
flexShrink: 0,
mt: 0.1,
}}
>
<Star size={16} />
</Box>
<Typography
variant="body2"
sx={{
fontSize: '0.8rem',
color: 'text.secondary',
lineHeight: 1.6,
}}
>
После получения заказа вы можете оставить отзыв в личном кабинете.
</Typography>
</Box>
</Box>
)
}
@@ -0,0 +1,247 @@
import Box from '@mui/material/Box'
import Stack from '@mui/material/Stack'
import Typography from '@mui/material/Typography'
import { ORDER_STATUS_DATA, type StatusIconName } from '@/shared/lib/order-status-data'
const iconMap: Record<StatusIconName, React.ReactElement> = {
banknote: (
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect x="2" y="6" width="20" height="12" rx="2" />
<circle cx="12" cy="12" r="3" />
<path d="M6 12h.01M18 12h.01" />
</svg>
),
'check-circle': (
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" />
<polyline points="22 4 12 14.01 9 11.01" />
</svg>
),
'package-search': (
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<circle cx="16" cy="12" r="4" />
<path d="M19 19l-3-3" />
<path d="M21 10V7a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 7v3" />
<path d="M3 10l9 5 9-5" />
</svg>
),
package: (
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M16.5 9.4l-9-5.19M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z" />
<polyline points="3.27 6.96 12 12.01 20.73 6.96" />
<line x1="12" y1="22.08" x2="12" y2="12" />
</svg>
),
'package-check': (
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M16.5 9.4l-9-5.19M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z" />
<polyline points="3.27 6.96 12 12.01 20.73 6.96" />
<line x1="12" y1="22.08" x2="12" y2="12" />
<polyline points="9 13 11 15 15 11" />
</svg>
),
store: (
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
<polyline points="9 22 9 12 15 12 15 22" />
</svg>
),
'x-circle': (
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<circle cx="12" cy="12" r="10" />
<path d="M15 9l-6 6M9 9l6 6" />
</svg>
),
}
const colorMap: Record<string, { bg: string; border: string; text: string; dot: string; iconBg: string }> = {
warning: {
bg: 'rgba(245, 124, 0, 0.04)',
border: 'rgba(245, 124, 0, 0.15)',
text: '#c27a00',
dot: '#F57C00',
iconBg: 'rgba(245, 124, 0, 0.1)',
},
success: {
bg: 'rgba(46, 139, 87, 0.04)',
border: 'rgba(46, 139, 87, 0.15)',
text: '#1e6e42',
dot: '#2E8B57',
iconBg: 'rgba(46, 139, 87, 0.1)',
},
info: {
bg: 'rgba(84, 110, 122, 0.04)',
border: 'rgba(84, 110, 122, 0.15)',
text: '#3d5a68',
dot: '#546E7A',
iconBg: 'rgba(84, 110, 122, 0.1)',
},
error: {
bg: 'rgba(211, 47, 47, 0.04)',
border: 'rgba(211, 47, 47, 0.15)',
text: '#a83232',
dot: '#D32F2F',
iconBg: 'rgba(211, 47, 47, 0.1)',
},
}
export function OrderStatusesSection() {
return (
<Box>
<Box sx={{ mb: 4 }}>
<Typography
variant="h5"
sx={{
fontWeight: 700,
letterSpacing: '-0.03em',
lineHeight: 1.15,
mb: 1,
}}
>
Статусы заказа
</Typography>
<Typography
sx={{
fontSize: '0.875rem',
color: 'text.secondary',
lineHeight: 1.6,
maxWidth: '56ch',
}}
>
Текущий статус отображается в личном кабинете. Каждый этап отражает, что происходит с вашим заказом.
</Typography>
</Box>
<Stack spacing={0} sx={{ position: 'relative' }}>
{ORDER_STATUS_DATA.map((s, index) => {
const colors = colorMap[s.color] ?? colorMap.info
const isLast = index === ORDER_STATUS_DATA.length - 1
return (
<Box
key={s.code}
sx={{
display: 'flex',
gap: 3,
pb: isLast ? 0 : 3,
position: 'relative',
}}
>
{/* Content column */}
<Box sx={{ flex: 1, pt: 0.25 }}>
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 1.5,
mb: 0.75,
}}
>
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: 36,
height: 36,
borderRadius: '10px',
backgroundColor: colors.iconBg,
color: colors.text,
flexShrink: 0,
}}
>
{iconMap[s.iconName]}
</Box>
<Typography
sx={{
fontWeight: 600,
fontSize: '0.9rem',
color: colors.text,
letterSpacing: '-0.01em',
}}
>
{s.label}
</Typography>
</Box>
<Typography
sx={{
fontSize: '0.8rem',
color: 'text.secondary',
lineHeight: 1.65,
maxWidth: '60ch',
pl: '45px',
}}
>
{s.description}
</Typography>
</Box>
</Box>
)
})}
</Stack>
</Box>
)
}
@@ -1,42 +1,103 @@
import List from '@mui/material/List'
import ListItem from '@mui/material/ListItem'
import ListItemIcon from '@mui/material/ListItemIcon'
import ListItemText from '@mui/material/ListItemText'
import Paper from '@mui/material/Paper'
import Box from '@mui/material/Box'
import Stack from '@mui/material/Stack'
import Typography from '@mui/material/Typography'
import { Banknote, CreditCard } from 'lucide-react'
const methods = [
{
icon: <CreditCard size={22} />,
primary: 'Банковская карта онлайн',
secondary: 'Оплата картой Visa, Mastercard или МИР сразу при оформлении заказа.',
icon: <CreditCard size={18} />,
primary: 'Онлайн-оплата через ЮKassa',
secondary: 'Карты, СБП. Оплата после подтверждения заказа админом. Перенаправление на защищённую платёжную страницу.',
},
{
icon: <Banknote size={22} />,
icon: <Banknote size={18} />,
primary: 'Оплата при получении',
secondary: 'Оплата наличными или картой при получении заказа.',
secondary: 'Наличными или картой при самовывозе.',
},
]
export function PaymentSection() {
return (
<Paper variant="outlined" sx={{ p: 3, borderRadius: 2 }}>
<Typography variant="h5" gutterBottom>
<Box>
<Typography
variant="h5"
sx={{
fontWeight: 700,
letterSpacing: '-0.03em',
lineHeight: 1.15,
mb: 1,
}}
>
Оплата
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
Оплата происходит после подтверждения заказа мастером. Вы получите уведомление, когда заказ будет подтверждён и
готов к оплате.
<Typography
sx={{
fontSize: '0.8rem',
color: 'text.secondary',
lineHeight: 1.65,
mb: 3,
maxWidth: '56ch',
}}
>
Оплата происходит после подтверждения заказа админом. Вы получите уведомление, когда заказ будет подтверждён и готов к оплате.
</Typography>
<List disablePadding>
<Stack spacing={2}>
{methods.map((m) => (
<ListItem key={m.primary} disableGutters>
<ListItemIcon sx={{ minWidth: 40 }}>{m.icon}</ListItemIcon>
<ListItemText primary={m.primary} secondary={m.secondary} />
</ListItem>
<Stack
key={m.primary}
direction="row"
spacing={2}
sx={{
alignItems: 'flex-start',
p: 2.5,
borderRadius: '12px',
border: '1px solid',
borderColor: 'divider',
transition: 'all 0.2s ease',
'&:hover': {
borderColor: 'action.disabledBackground',
},
}}
>
<Box
sx={{
width: 36,
height: 36,
borderRadius: '8px',
backgroundColor: 'action.hover',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: 'text.secondary',
flexShrink: 0,
}}
>
{m.icon}
</Box>
<Stack spacing={0.25}>
<Typography
sx={{
fontWeight: 600,
fontSize: '0.875rem',
letterSpacing: '-0.01em',
}}
>
{m.primary}
</Typography>
<Typography
sx={{
fontSize: '0.8rem',
color: 'text.secondary',
lineHeight: 1.6,
}}
>
{m.secondary}
</Typography>
</Stack>
</Stack>
))}
</List>
</Paper>
</Stack>
</Box>
)
}
@@ -1,35 +1,67 @@
import Paper from '@mui/material/Paper'
import Box from '@mui/material/Box'
import Stack from '@mui/material/Stack'
import Typography from '@mui/material/Typography'
export function ReturnsSection() {
return (
<Paper variant="outlined" sx={{ p: 3, borderRadius: 2 }}>
<Typography variant="h5" gutterBottom>
<Box>
<Typography
variant="h5"
sx={{
fontWeight: 700,
letterSpacing: '-0.03em',
lineHeight: 1.15,
mb: 3,
}}
>
Возврат и гарантии
</Typography>
<Stack spacing={2}>
<Paper variant="outlined" sx={{ p: 2, borderRadius: 2 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 600 }} gutterBottom>
<Stack spacing={2.5}>
<Box>
<Typography
sx={{
fontWeight: 600,
fontSize: '0.875rem',
letterSpacing: '-0.01em',
mb: 1,
}}
>
Возврат
</Typography>
<Typography variant="body2" color="text.secondary">
Если товар не соответствует описанию или имеет производственный дефект, свяжитесь с нами в течение 7 дней
после получения. Мы заменим изделие на аналогичное или вернём деньги. Возврат товара надлежащего качества
возможен в течение 14 дней, если изделие не было в употреблении и сохранён его товарный вид.
<Typography
sx={{
fontSize: '0.8rem',
color: 'text.secondary',
lineHeight: 1.65,
}}
>
Если товар не соответствует описанию или имеет производственный дефект, свяжитесь с нами в течение 7 дней после получения. Мы заменим изделие на аналогичное или вернём деньги. Возврат товара надлежащего качества возможен в течение 14 дней, если изделие не было в употреблении и сохранён его товарный вид.
</Typography>
</Paper>
<Paper variant="outlined" sx={{ p: 2, borderRadius: 2 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 600 }} gutterBottom>
</Box>
<Box>
<Typography
sx={{
fontWeight: 600,
fontSize: '0.875rem',
letterSpacing: '-0.01em',
mb: 1,
}}
>
Гарантия качества
</Typography>
<Typography variant="body2" color="text.secondary">
Мы отвечаем за качество каждого изделия ручной работы. Все дефекты, возникшие не по вине покупателя,
устраняются или компенсируются заменой изделия. Если у вас возникли вопросы по качеству напишите нам, и мы
решим проблему в кратчайшие сроки.
<Typography
sx={{
fontSize: '0.8rem',
color: 'text.secondary',
lineHeight: 1.65,
}}
>
Мы отвечаем за качество каждого изделия ручной работы. Все дефекты, возникшие не по вине покупателя, устраняются или компенсируются заменой изделия. Если у вас возникли вопросы по качеству напишите нам, и мы решим проблему в кратчайшие сроки.
</Typography>
</Paper>
</Box>
</Stack>
</Paper>
</Box>
)
}
@@ -10,6 +10,8 @@ import { useMutation } from '@tanstack/react-query'
import { useUnit } from 'effector-react'
import { useForm } from 'react-hook-form'
import { useSearchParams } from 'react-router-dom'
import { getErrorMessage } from '@/shared/lib/get-error-message'
import { isSyntheticEmail } from '@/shared/lib/is-synthetic-email'
import {
$user,
changePasswordFx,
@@ -19,7 +21,6 @@ import {
unlinkOAuthFx,
type AuthMethod,
} from '@/shared/model/auth'
import { getErrorMessage } from '@/shared/lib/get-error-message'
const METHOD_LABELS: Record<string, string> = { password: 'Пароль', vk: 'ВКонтакте', yandex: 'Яндекс' }
@@ -117,6 +118,13 @@ export function AuthMethodsSection() {
{user.email}
</Typography>
{isSyntheticEmail(user.email) && (
<Alert severity="info" sx={{ mb: 2 }}>
Ваша почта сгенерирована автоматически. Без указания реальной почты вы не сможете получать уведомления о
заказах.
</Alert>
)}
{!verificationUrl && (
<Stack direction="row" spacing={1} sx={{ mb: 2 }}>
<TextField
@@ -0,0 +1,92 @@
import { useState } from 'react'
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 DialogContentText from '@mui/material/DialogContentText'
import DialogTitle from '@mui/material/DialogTitle'
import Divider from '@mui/material/Divider'
import Stack from '@mui/material/Stack'
import Typography from '@mui/material/Typography'
import { useMutation, useQuery } from '@tanstack/react-query'
import { useNavigate } from 'react-router-dom'
import { fetchMyOrders } from '@/entities/order'
import { apiClient } from '@/shared/api/client'
import { getApiErrorMessage } from '@/shared/lib/get-api-error-message'
import { logout, tokenSet } from '@/shared/model/auth'
const ACTIVE_STATUSES = ['DRAFT', 'PENDING_PAYMENT', 'PAID', 'IN_PROGRESS', 'SHIPPED', 'READY_FOR_PICKUP']
export function DeleteAccountSection() {
const navigate = useNavigate()
const [dialogOpen, setDialogOpen] = useState(false)
const ordersQuery = useQuery({
queryKey: ['my-orders'],
queryFn: fetchMyOrders,
staleTime: 30_000,
})
const activeOrders = ordersQuery.data?.items.filter((o) => ACTIVE_STATUSES.includes(o.status)) ?? []
const hasActiveOrders = activeOrders.length > 0
const deleteMutation = useMutation({
mutationFn: async () => {
await apiClient.delete('me')
},
onSuccess: () => {
tokenSet(null)
logout()
setDialogOpen(false)
navigate('/')
},
})
return (
<>
<Divider />
<Stack spacing={2}>
<Typography variant="h6" color="error">
Удаление аккаунта
</Typography>
<Button variant="outlined" color="error" onClick={() => setDialogOpen(true)} sx={{ alignSelf: 'start' }}>
Удалить аккаунт
</Button>
{deleteMutation.error && (
<Typography variant="caption" color="error">
{getApiErrorMessage(deleteMutation.error) || 'Не удалось удалить аккаунт'}
</Typography>
)}
</Stack>
<Dialog open={dialogOpen} onClose={() => setDialogOpen(false)}>
<DialogTitle>Удаление аккаунта</DialogTitle>
<DialogContent>
{hasActiveOrders && (
<DialogContentText sx={{ mb: 1, fontWeight: 500 }}>
У вас есть {activeOrders.length} незавершённых заказ
{activeOrders.length === 1 ? '' : activeOrders.length < 5 ? 'а' : 'ов'}. После удаления аккаунта
отслеживание заказов станет недоступным.
</DialogContentText>
)}
<DialogContentText>
Вы уверены? Все данные будут безвозвратно удалены. Восстановить аккаунт будет невозможно.
</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={() => setDialogOpen(false)}>Отмена</Button>
<Button
onClick={() => deleteMutation.mutate()}
variant="contained"
color="error"
disabled={deleteMutation.isPending}
>
Удалить
</Button>
</DialogActions>
</Dialog>
</>
)
}
@@ -6,11 +6,14 @@ import Stack from '@mui/material/Stack'
import Switch from '@mui/material/Switch'
import Typography from '@mui/material/Typography'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useUnit } from 'effector-react'
import {
fetchUserNotificationSettings,
updateUserNotificationSettings,
} from '@/entities/notification/api/notifications-api'
import type { UserNotificationSettings } from '@/entities/notification/api/notifications-api'
import { isSyntheticEmail } from '@/shared/lib/is-synthetic-email'
import { $user } from '@/shared/model/auth'
function isOrderStatusChangesOn(s: UserNotificationSettings): boolean {
return s.orderCreated && s.orderStatusChanged && s.paymentStatusChanged && s.deliveryFeeAdjusted
@@ -26,6 +29,7 @@ const orderStatusChangesPayload = (on: boolean) => ({
export function NotificationsPage() {
const queryClient = useQueryClient()
const [error, setError] = useState<string | null>(null)
const user = useUnit($user)
const { data, isLoading } = useQuery({
queryKey: ['me', 'notifications', 'settings'],
@@ -63,6 +67,12 @@ export function NotificationsPage() {
Настройте, какие уведомления вы хотите получать на почту.
</Typography>
{user && isSyntheticEmail(user.email) && (
<Alert severity="info" sx={{ mb: 2 }}>
Ваша почта сгенерирована автоматически. Для получения уведомлений укажите реальную почту в настройках профиля.
</Alert>
)}
{error && (
<Alert severity="error" sx={{ mb: 2 }}>
{error}
@@ -25,7 +25,7 @@ import { deliveryCarrierLabelRu } from '@/shared/constants/delivery-carrier'
import { PICKUP_ADDRESS_FULL } from '@/shared/constants/pickup-point'
import { formatPriceRub } from '@/shared/lib/format-price'
import { parseOrderAddressSnapshot } from '@/shared/lib/order-address-snapshot'
import { orderStatusLabelRu } from '@/shared/lib/order-status-labels'
import { OrderStatusChip } from '@/shared/ui/OrderStatusChip'
export function OrderDetailPage() {
const { id } = useParams()
@@ -130,7 +130,14 @@ export function OrderDetailPage() {
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} sx={{ mb: 2, alignItems: { sm: 'center' } }}>
<Box sx={{ flexGrow: 1 }}>
<Typography variant="h4">Заказ #{order.id.slice(-6)}</Typography>
<Typography color="text.secondary">Статус: {orderStatusLabelRu(order.status)}</Typography>
<OrderStatusChip
status={order.status}
tooltipOverride={
order.status === 'PENDING_PAYMENT' && order.deliveryType === 'delivery' && !order.deliveryFeeLocked
? 'Оплата происходит после подтверждения заказа админом. Вы получите уведомление, когда заказ будет подтверждён и готов к оплате.'
: undefined
}
/>
</Box>
<Button component={RouterLink} to="/me/orders" variant="outlined">
К списку
@@ -7,6 +7,7 @@ import { useUnit } from 'effector-react'
import { $user } from '@/shared/model/auth'
import { AuthMethodsSection } from './AuthMethodsSection'
import { AvatarSection } from './AvatarSection'
import { DeleteAccountSection } from './DeleteAccountSection'
import { ProfileSection } from './ProfileSection'
export function SettingsPage() {
@@ -35,6 +36,7 @@ export function SettingsPage() {
<AuthMethodsSection />
</>
)}
<DeleteAccountSection />
</Stack>
</Box>
)
@@ -1,46 +1,47 @@
import Box from '@mui/material/Box'
import Paper from '@mui/material/Paper'
import Typography from '@mui/material/Typography'
import { STORE_EMAIL } from '@/shared/config'
import {
STORE_EMAIL,
STORE_OP_NAME,
STORE_OP_TYPE,
STORE_OP_INN,
STORE_OP_ADDR,
STORE_PUBLIC_SITE_URL,
} from '@/shared/config'
const OP_NAME = 'Индивидуальный предприниматель Новоселова Наталия Владимировна'
const OP_INN = '402900832341'
const OP_OGRN = '305402922700051'
const OP_ADDR = '248000, Россия, г. Калуга, ул. Никитина, д. 12А'
const SITE_URL = window.location.origin
const SITE_URL = STORE_PUBLIC_SITE_URL || (typeof window !== 'undefined' ? window.location.origin : '')
const OP_FULL = `${STORE_OP_NAME} (${STORE_OP_TYPE})`
const sections = [
{
title: '1. Общие положения',
items: [
`1.1. Настоящая Политика конфиденциальности (далее — Политика) действует в отношении всех персональных данных, которые ${OP_NAME} (далее — Оператор) может получить от Пользователя во время использования сайта ${SITE_URL}.`,
`1.2. ИНН Оператора: ${OP_INN}`,
`1.3. ОГРН/ОГРНИП Оператора: ${OP_OGRN}`,
`1.4. Адрес Оператора: ${OP_ADDR}`,
`1.5. Контактный email: ${STORE_EMAIL}`,
`1.1. Настоящая Политика конфиденциальности (далее — Политика) действует в отношении всех персональных данных, которые ${OP_FULL} (далее — Оператор) может получить от Пользователя во время использования сайта ${SITE_URL}.`,
`1.2. ИНН Оператора: ${STORE_OP_INN}`,
`1.3. Адрес Оператора: ${STORE_OP_ADDR}`,
`1.4. Контактный email: ${STORE_EMAIL}`,
],
},
{
title: '2. Персональные данные, которые обрабатывает Оператор',
items: [
'2.1. Оператор обрабатывает следующие персональные данные Пользователей:',
'— фамилия, имя, отчество;',
'— адрес электронной почты;',
'— номер телефона;',
'— данные файлов cookie;',
'— данные о действиях на сайте (аналитика);',
'— адрес доставки и геолокационные координаты.',
'— имя (отображаемое имя, может быть указано Пользователем добровольно);',
'— ФИО получателя, номер телефона и адрес доставки (указывается Пользователем добровольно при оформлении доставки);',
'— сессионные cookie-файлы (исключительно для поддержания входа в Личный кабинет).',
],
},
{
title: '3. Цели обработки персональных данных',
items: [
'3.1. Оператор обрабатывает персональные данные в следующих целях:',
'— идентификация Пользователя;',
'— оказание услуг / продажа товаров;',
'— направление уведомлений и информационных сообщений;',
'— улучшение качества работы сайта;',
'— построение персонализированных предложений и рекомендаций.',
'— идентификация и аутентификация Пользователя;',
'— оказание услуг / продажа товаров и оформление доставки;',
'— направление транзакционных уведомлений о статусе заказов и информационных сообщений;',
'— улучшение качества работы сайта.',
],
},
{
@@ -56,9 +57,9 @@ const sections = [
{
title: '5. Порядок и условия обработки',
items: [
'5.1. Обработка осуществляется путём сбора, записи, систематизации, накопления, хранения, уточнения, извлечения, использования, передачи, обезличивания, блокирования, удаления и уничтожения персональных данных.',
'5.2. Обработка осуществляется автоматизированным и неавтоматизированным способами.',
'5.3. Срок хранения персональных данных: не более 7 лет с момента последнего обращения Пользователя либо до момента отзыва согласия на обработку.',
'5.1. Обработка осуществляется путём сбора, записи, систематизации, накопления, хранения, уточнения, извлечения, использования, передачи, блокирования, удаления и уничтожения персональных данных.',
'5.2. Обработка осуществляется автоматизированным способом с использованием программных средств Сайта.',
'5.3. Срок хранения персональных данных: до достижения целей обработки либо до момента отзыва Пользователем согласия на обработку.',
],
},
{
@@ -67,14 +68,23 @@ const sections = [
'6.1. Оператор может передать персональные данные третьим лицам в следующих случаях:',
'— с согласия субъекта;',
'— по требованию законодательства РФ;',
'— для выполнения договорных обязательств (перечень третьих лиц): службы доставки, платёжные агрегаторы, сервисы аналитики (Яндекс.Метрика).',
'— для выполнения договорных обязательств (перечень третьих лиц): службы доставки, платёжный сервис (ЮKassa).',
],
},
{
title: '7. Права субъекта персональных данных',
title: '7. Использование cookie-файлов',
items: [
'7.1. Пользователь имеет право на доступ к своим данным, их уточнение, блокирование или уничтожение.',
'7.2. Для реализации своих прав Пользователь может направить запрос на электронный адрес: ' + STORE_EMAIL,
'7.1. Оператор использует сессионные cookie-файлы исключительно для поддержания аутентификации Пользователя в Личном кабинете.',
'7.2. Сайт не использует cookie для сбора статистики, отслеживания действий Пользователя или показа рекламы.',
'7.3. Пользователь может запретить использование cookie в настройках браузера, однако это приведёт к невозможности входа в Личный кабинет.',
],
},
{
title: '8. Права субъекта персональных данных',
items: [
'8.1. Пользователь имеет право на доступ к своим данным, их уточнение или уничтожение.',
`8.2. Для реализации своих прав Пользователь может направить запрос на электронный адрес: ${STORE_EMAIL}.`,
'8.3. Пользователь вправе самостоятельно удалить свою учётную запись через Настройки Личного кабинета. При наличии активных (незавершённых) заказов система предупредит об этом перед удалением.',
],
},
]
@@ -96,7 +106,7 @@ export function PrivacyPolicyPage() {
color="text.secondary"
sx={{ mb: 4, pb: 3, borderBottom: '1px solid', borderColor: 'divider' }}
>
Политика в отношении обработки персональных данных.
Последнее обновление: 23 мая 2026 г.
</Typography>
<Box component="section" sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
+29 -27
View File
@@ -1,14 +1,19 @@
import Box from '@mui/material/Box'
import Paper from '@mui/material/Paper'
import Typography from '@mui/material/Typography'
import { STORE_EMAIL, STORE_PHONE, STORE_PUBLIC_SITE_URL } from '@/shared/config'
import {
STORE_EMAIL,
STORE_PHONE,
STORE_PUBLIC_SITE_URL,
STORE_OP_NAME,
STORE_OP_TYPE,
STORE_OP_INN,
STORE_OP_ADDR,
} from '@/shared/config'
const SITE_URL = STORE_PUBLIC_SITE_URL || (typeof window !== 'undefined' ? window.location.origin : '')
const OP_NAME = 'Индивидуальный предприниматель Новоселова Наталия Владимировна'
const OP_INN = '402900832341'
const OP_OGRN = '305402922700051'
const OP_ADDR = '248000, Россия, г. Калуга, ул. Никитина, д. 12А'
const OP_FULL = `${STORE_OP_NAME} (${STORE_OP_TYPE})`
const sections = [
{
@@ -17,9 +22,9 @@ const sections = [
`1.1. Настоящее Пользовательское соглашение (далее — «Соглашение») определяет порядок и условия использования материалов и сервисов, размещённых в сети Интернет по адресу ${SITE_URL} (далее — «Сайт»), Пользователями данного Сайта.`,
`1.2. Использование Пользователями Сайта означает, что они безоговорочно принимают и обязуются соблюдать все условия настоящего Соглашения.`,
'1.3. В настоящем Соглашении используются следующие термины:',
`— Администратор — ${OP_NAME}, ИНН ${OP_INN}, ОГРН ${OP_OGRN}, адрес: ${OP_ADDR}, которому принадлежат все соответствующие права на Сайт.`,
`— Администратор — ${OP_FULL}, ИНН ${STORE_OP_INN}, адрес: ${STORE_OP_ADDR}, которому принадлежат все соответствующие права на Сайт.`,
`— Акцепт — полное и безоговорочное принятие условий настоящего Соглашения, размещённого на Сайте по адресу ${SITE_URL}/terms, осуществляемое путём совершения Пользователем любых действий по использованию Сайта.`,
'— Аутентификационные данные Пользователя — адрес электронной почты Пользователя и пароль (код доступа), которые в совокупности признаются простой электронной подписью Пользователя.',
'— Аутентификационные данные Пользователя — адрес электронной почты и пароль (код доступа), либо данные, полученные через сервисы авторизации третьих лиц (VK ID, Яндекс ID), либо одноразовый код, направляемый на электронную почту. Совокупность аутентификационных данных признаётся простой электронной подписью Пользователя.',
'— Пользователь — лицо, осуществляющее доступ к Сайту и использующее материалы и сервисы, размещённые на Сайте.',
'— Контент — любое информационно значимое наполнение Сайта, включая фото, текст и иные медиаматериалы.',
'— Личный кабинет — персонализированная часть Сайта, посредством которой обеспечивается обмен информацией между Пользователем и Сайтом.',
@@ -53,7 +58,7 @@ const sections = [
'3.4. Пользователь самостоятельно несёт ответственность за сохранность своих Аутентификационных данных и за все действия, совершённые с их использованием.',
'3.5. Пользователь обязан незамедлительно уведомить Администратора о любом случае несанкционированного доступа к Личному кабинету.',
'3.6. Пользователь не вправе воспроизводить, копировать, продавать и использовать в коммерческих целях Сайт или его Контент без разрешения Администратора.',
'3.7. При регистрации Пользователь даёт согласие на получение информационных и рекламных сообщений от Администратора на указанный адрес электронной почты.',
'3.7. При регистрации Пользователь даёт согласие на получение транзакционных уведомлений (статус заказа, сообщения в чате заказа, статус оплаты) на указанный адрес электронной почты.',
],
},
{
@@ -79,12 +84,13 @@ const sections = [
'5.6. Пользователь не вправе нарушать нормальную работу отдельных сервисов и Сайта в целом.',
'5.7. Пользователь обязан самостоятельно отслеживать внесение изменений в настоящее Соглашение.',
'5.8. Пользователь вправе прекратить доступ к Личному кабинету, направив уведомление Администратору.',
'5.9. Пользователь вправе самостоятельно удалить свою учётную запись через Настройки Личного кабинета.',
],
},
{
title: '6. Ограничение ответственности',
items: [
'6.1. Администратор гарантирует достоверность и полноту только той информации, которую он разместил на Сайте самостоятельно.',
'6.1. Администратор прилагает разумные усилия для обеспечения достоверности и полноты информации, размещённой на Сайте, однако не даёт явных гарантий точности такой информации.',
'6.2. Администратор не несёт ответственности за недостоверность информации, размещённой третьими лицами, в том числе Пользователями.',
'6.3. Администратор не гарантирует, что Сайт будет соответствовать требованиям Пользователя, работать непрерывно и без ошибок.',
'6.4. Администратор не несёт ответственности перед Пользователем за любые убытки, включая упущенную выгоду, потерю данных, вред деловой репутации, причинённые в связи с использованием Сайта.',
@@ -95,21 +101,17 @@ const sections = [
{
title: '7. Доступ к ресурсам третьих лиц',
items: [
'7.1. Доступ Пользователя к Сайту может вызывать обращение к интернет-ресурсам третьих лиц (реклама, сбор статистики).',
'7.2. Владельцы таких ресурсов имеют техническую возможность собирать информацию о Пользователях и самостоятельно определяют условия её использования.',
'7.1. Для обеспечения функциональности Сайта используются сервисы третьих лиц: платёжный сервис ЮKassa (для обработки онлайн-платежей)',
'7.2. Владельцы указанных ресурсов имеют собственную политику конфиденциальности и самостоятельно определяют условия обработки получаемой информации.',
'7.3. При переходе на сторонние ресурсы Пользователи самостоятельно определяют пределы использования своей информации согласно правилам соответствующих ресурсов.',
],
},
{
title: '8. Информация, хранящаяся на стороне браузера',
items: [
'8.1. Администратор использует cookie-файлы для определения уникального идентификатора доступа Пользователя к Сайту.',
'8.2. Цели использования cookie:',
'— поддержка функциональности Сайта, требующей использования cookie;',
'— измерение аудитории Сайта;',
'— определение статистических предпочтений Пользователей;',
'— исследование корреляции статистических данных.',
'8.3. Пользователь может запретить использование cookie в настройках браузера, однако это может привести к частичной или полной потере функциональности Сайта.',
'8.1. Администратор использует сессионные cookie-файлы исключительно для поддержания аутентификации Пользователя в Личном кабинете.',
'8.2. Сайт не использует cookie для сбора статистики, отслеживания действий Пользователя или показа рекламы.',
'8.3. Пользователь может запретить использование cookie в настройках браузера, однако это приведёт к невозможности входа в Личный кабинет и использования функций, требующих аутентификации.',
],
},
{
@@ -117,16 +119,16 @@ const sections = [
items: [
`9.1. Обработка персональных данных Пользователей осуществляется Администратором в соответствии с Политикой конфиденциальности, размещённой по адресу ${SITE_URL}/privacy.`,
'9.2. Передавая свои персональные данные при регистрации или заполнении форм на Сайте, Пользователь даёт согласие на их обработку Администратором.',
'9.3. Администратор обрабатывает следующие персональные данные: Ф. И. О., адрес электронной почты, номер телефона, IP-адрес, тип браузера, данные о действиях на Сайте.',
'9.4. Цели обработки персональных данных: обеспечение функционирования Сайта, оказание информационной поддержки, предоставление персонализированных сервисов, направление информационных сообщений.',
`9.5. Согласие на обработку персональных данных может быть отозвано Пользователем путём направления заявления на адрес электронной почты: ${STORE_EMAIL}.`,
'9.3. Администратор обрабатывает следующие персональные данные: адрес электронной почты, имя (при добровольном указании), номер телефона (при оформлении доставки), адрес доставки.',
'9.4. Цели обработки персональных данных: обеспечение функционирования Сайта, аутентификация Пользователя, оформление и доставка заказов, направление транзакционных уведомлений.',
`9.5. Пользователь вправе отозвать согласие на обработку персональных данных путём удаления учётной записи через Настройки Личного кабинета либо путём направления заявления на адрес электронной почты: ${STORE_EMAIL}.`,
],
},
{
title: '10. Изменение условий и расторжение соглашения',
items: [
'10.1. Соглашение может быть расторгнуто в любое время по инициативе любой из сторон. Администратор уведомляет о расторжении путём размещения информации на Сайте.',
`10.2. Пользователь может расторгнуть Соглашение, направив уведомление на адрес электронной почты: ${STORE_EMAIL}.`,
`10.2. Пользователь может расторгнуть Соглашение, удалив учётную запись через Личный кабинет, либо направив уведомление на адрес электронной почты: ${STORE_EMAIL}.`,
'10.3. Администратор вправе в одностороннем порядке изменять условия Соглашения. Новая редакция вступает в силу с момента размещения на Сайте.',
'10.4. Продолжение использования Сайта после изменения условий означает согласие Пользователя с новой редакцией. При несогласии Пользователь обязуется прекратить использование Сайта.',
],
@@ -134,10 +136,10 @@ const sections = [
{
title: '11. Информация об Администраторе',
items: [
`${OP_NAME}`,
`ИНН: ${OP_INN}`,
`ОГРН: ${OP_OGRN}`,
`Адрес: ${OP_ADDR}`,
STORE_OP_NAME,
`Статус: ${STORE_OP_TYPE}`,
`ИНН: ${STORE_OP_INN}`,
`Адрес: ${STORE_OP_ADDR}`,
`Телефон: ${STORE_PHONE}`,
`Email: ${STORE_EMAIL}`,
],
@@ -161,7 +163,7 @@ export function TermsPage() {
color="text.secondary"
sx={{ mb: 4, pb: 3, borderBottom: '1px solid', borderColor: 'divider' }}
>
Последнее обновление: 19 мая 2026 г.
Последнее обновление: 23 мая 2026 г.
</Typography>
<Box component="section" sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
+10 -2
View File
@@ -1,7 +1,6 @@
import axios, { AxiosHeaders } from 'axios'
import { apiBaseURL } from '@/shared/config'
// Глобальный application/json ломает FormData: axios сериализует его в JSON. Тип для JSON задаёт transformRequest.
export const apiClient = axios.create({
baseURL: apiBaseURL,
})
@@ -13,7 +12,6 @@ apiClient.interceptors.request.use((config) => {
if (token) {
config.headers.set('Authorization', `Bearer ${token}`)
}
// FormData: нельзя задавать Content-Type вручную (нужен boundary). Иначе сервер не видит файлы → { urls: [] }.
if (config.data instanceof FormData) {
config.headers.delete('Content-Type')
config.headers.delete('content-type')
@@ -23,3 +21,13 @@ apiClient.interceptors.request.use((config) => {
return config
}
})
apiClient.interceptors.response.use(undefined, (error) => {
const ct = error.response?.headers?.['content-type'] ?? ''
if (error.response?.status === 403 && ct.includes('text/html')) {
document.open()
document.write(error.response.data)
document.close()
}
return Promise.reject(error)
})
+9 -2
View File
@@ -3,16 +3,23 @@ export const apiBaseURL = import.meta.env.VITE_API_URL ?? '/api'
export const STORE_NAME = 'Любимый Креатив'
/** Канонический URL сайта для политики конфиденциальности и т.п.; в dev без env — `window.location.origin`. */
/** Канонический URL сайта для юридических текстов (политика конфиденциальности и т.п.). */
export const STORE_PUBLIC_SITE_URL = (() => {
const raw =
typeof import.meta.env.VITE_PUBLIC_SITE_URL === 'string' ? import.meta.env.VITE_PUBLIC_SITE_URL.trim() : ''
if (raw) return raw.replace(/\/$/, '')
if (typeof window !== 'undefined') return window.location.origin
return ''
return 'https://любимыйкреатив.рф'
})()
/** Демо-контакты для футера; при необходимости задайте через VITE_* в `.env`. */
export const STORE_EMAIL = import.meta.env.VITE_STORE_EMAIL ?? 'larisa8502@yandex.ru'
export const STORE_PHONE = import.meta.env.VITE_STORE_PHONE ?? '+7 (952) 318-16-24'
export const VK_URL = import.meta.env.VITE_VK_URL ?? 'https://vk.com/club158395871'
/** Данные оператора для юридических документов. */
export const STORE_OP_NAME = 'Комарова Лариса Николаевна'
export const STORE_OP_TYPE = 'Самозанятый'
export const STORE_OP_INN = '591878584346'
export const STORE_OP_ADDR =
'618909, Россия, Пермский край, Лысьвенский муниципальный округ, Лысьва, улица Мира, 34, кв. 24'
+4 -1
View File
@@ -3,4 +3,7 @@ export const PICKUP_COORDINATES = { lat: 58.09898000206914, lng: 57.813169680997
/** Полная строка адреса для текстовых блоков. */
export const PICKUP_ADDRESS_FULL =
'34, улица Мира, Лысьва, Лысьвенский муниципальный округ, Пермский край, Приволжский федеральный округ, 618909, Россия'
'618909, Россия, Пермский край, Лысьвенский муниципальный округ, Лысьва, улица Мира, 34'
/** Короткий адрес для компактных блоков. */
export const PICKUP_ADDRESS_SHORT = 'Лысьва, ул. Мира, 34'
@@ -0,0 +1,24 @@
import { describe, expect, it } from 'vitest'
import { isSyntheticEmail } from '../is-synthetic-email'
describe('isSyntheticEmail', () => {
it('returns true for vk.local domain', () => {
expect(isSyntheticEmail('vk_12345@vk.local')).toBe(true)
})
it('returns false for real email', () => {
expect(isSyntheticEmail('user@gmail.com')).toBe(false)
})
it('returns false for yandex email', () => {
expect(isSyntheticEmail('user@yandex.ru')).toBe(false)
})
it('returns false for empty string', () => {
expect(isSyntheticEmail('')).toBe(false)
})
it('returns false for email with vk.local as part of username', () => {
expect(isSyntheticEmail('vk.local@gmail.com')).toBe(false)
})
})
@@ -0,0 +1,5 @@
const SYNTHETIC_DOMAINS = ['vk.local']
export function isSyntheticEmail(email: string): boolean {
return SYNTHETIC_DOMAINS.some((domain) => email.endsWith(`@${domain}`))
}
@@ -0,0 +1,67 @@
export type StatusColor = 'warning' | 'success' | 'info' | 'error'
export type StatusIconName = 'banknote' | 'check-circle' | 'package-search' | 'package' | 'package-check' | 'store' | 'x-circle'
export interface OrderStatusData {
code: string
label: string
iconName: StatusIconName
color: StatusColor
description: string
}
export const ORDER_STATUS_DATA: ReadonlyArray<OrderStatusData> = [
{
code: 'PENDING_PAYMENT',
label: 'Ожидает оплаты',
iconName: 'banknote',
color: 'warning',
description:
'Заказ оформлен и подтверждён администратором. Оплатите онлайн через ЮKassa или дождитесь получения (для самовывоза с оплатой при получении).',
},
{
code: 'PAID',
label: 'Оплачен',
iconName: 'check-circle',
color: 'success',
description: 'Оплата получена. Админ скоро возьмёт заказ в работу.',
},
{
code: 'IN_PROGRESS',
label: 'Подготовка к отправке',
iconName: 'package-search',
color: 'info',
description: 'Админ готовит заказ к отправке или выдаче. Скоро статус обновится.',
},
{
code: 'SHIPPED',
label: 'Отправлен',
iconName: 'package',
color: 'info',
description: 'Заказ передан в службу доставки. Трек-номер для отслеживания(при наличии) будет указан в сообщении админа.',
},
{
code: 'READY_FOR_PICKUP',
label: 'Готов к получению',
iconName: 'store',
color: 'success',
description: 'Заказ готов к самовывозу. Приезжайте в согласованное время.',
},
{
code: 'DONE',
label: 'Завершён',
iconName: 'package-check',
color: 'success',
description: 'Заказ получен. Вы можете оставить отзыв в личном кабинете.',
},
{
code: 'CANCELLED',
label: 'Отменён',
iconName: 'x-circle',
color: 'error',
description: 'Заказ отменён. Если оплата была произведена, средства вернутся на карту.',
},
]
export function getOrderStatusData(code: string): OrderStatusData | undefined {
return ORDER_STATUS_DATA.find((s) => s.code === code)
}
+1 -1
View File
@@ -4,7 +4,7 @@ export function orderStatusLabelRu(code: string): string {
DRAFT: 'Черновик',
PENDING_PAYMENT: 'Ожидает оплаты',
PAID: 'Оплачен',
IN_PROGRESS: 'В работе',
IN_PROGRESS: 'Подготовка к отправке',
SHIPPED: 'Отправлен',
READY_FOR_PICKUP: 'Готово к получению',
DONE: 'Завершён',
@@ -0,0 +1,71 @@
import { useState } from 'react'
import Box from '@mui/material/Box'
import Button from '@mui/material/Button'
import Link from '@mui/material/Link'
import Typography from '@mui/material/Typography'
import { Link as RouterLink } from 'react-router-dom'
const STORAGE_KEY = 'cookie-consent-accepted'
function wasAccepted(): boolean {
try {
return localStorage.getItem(STORAGE_KEY) === '1'
} catch {
return false
}
}
function markAccepted() {
try {
localStorage.setItem(STORAGE_KEY, '1')
} catch {
// ignore
}
}
export function CookieConsentBanner() {
const [visible, setVisible] = useState(!wasAccepted())
if (!visible) return null
return (
<Box
sx={{
position: 'fixed',
bottom: 0,
left: 0,
right: 0,
zIndex: 1300,
bgcolor: 'background.paper',
borderTop: 1,
borderColor: 'divider',
px: 2,
py: 1.5,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: 2,
flexWrap: 'wrap',
boxShadow: 3,
}}
>
<Typography variant="body2" color="text.secondary">
Мы используем cookie для поддержания сессии. Продолжая использовать сайт, вы принимаете{' '}
<Link component={RouterLink} to="/privacy" underline="hover">
Политику обработки персональных данных
</Link>
.
</Typography>
<Button
variant="contained"
size="small"
onClick={() => {
markAccepted()
setVisible(false)
}}
>
Понятно
</Button>
</Box>
)
}
+158
View File
@@ -0,0 +1,158 @@
import { type ReactNode, useState } from 'react'
import Box from '@mui/material/Box'
import Tooltip from '@mui/material/Tooltip'
import Typography from '@mui/material/Typography'
import { getOrderStatusData, type StatusIconName } from '@/shared/lib/order-status-data'
const iconMap: Record<StatusIconName, ReactNode> = {
banknote: (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<rect x="2" y="6" width="20" height="12" rx="2" />
<circle cx="12" cy="12" r="3" />
<path d="M6 12h.01M18 12h.01" />
</svg>
),
'check-circle': (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" />
<polyline points="22 4 12 14.01 9 11.01" />
</svg>
),
'package-search': (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M16 16a4 4 0 1 0 0-8 4 4 0 0 0 0 8z" />
<path d="M19 19l-3-3" />
<path d="M21 10V7a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 7v3" />
<path d="M3 10l9 5 9-5" />
</svg>
),
package: (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M16.5 9.4l-9-5.19M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z" />
<polyline points="3.27 6.96 12 12.01 20.73 6.96" />
<line x1="12" y1="22.08" x2="12" y2="12" />
</svg>
),
'package-check': (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M16.5 9.4l-9-5.19M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z" />
<polyline points="3.27 6.96 12 12.01 20.73 6.96" />
<line x1="12" y1="22.08" x2="12" y2="12" />
<polyline points="9 13 11 15 15 11" />
</svg>
),
store: (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
<polyline points="9 22 9 12 15 12 15 22" />
</svg>
),
'x-circle': (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10" />
<path d="M15 9l-6 6M9 9l6 6" />
</svg>
),
}
const colorMap: Record<string, { bg: string; border: string; text: string; dot: string }> = {
warning: {
bg: 'rgba(245, 124, 0, 0.06)',
border: 'rgba(245, 124, 0, 0.25)',
text: '#c27a00',
dot: '#F57C00',
},
success: {
bg: 'rgba(46, 139, 87, 0.06)',
border: 'rgba(46, 139, 87, 0.25)',
text: '#1e6e42',
dot: '#2E8B57',
},
info: {
bg: 'rgba(84, 110, 122, 0.06)',
border: 'rgba(84, 110, 122, 0.25)',
text: '#3d5a68',
dot: '#546E7A',
},
error: {
bg: 'rgba(211, 47, 47, 0.06)',
border: 'rgba(211, 47, 47, 0.25)',
text: '#a83232',
dot: '#D32F2F',
},
}
interface OrderStatusChipProps {
status: string
tooltipOverride?: string
size?: 'sm' | 'md'
}
export function OrderStatusChip({ status, tooltipOverride, size = 'md' }: OrderStatusChipProps) {
const data = getOrderStatusData(status)
const label = data?.label ?? status
const colorKey = data?.color ?? 'default'
const icon = data ? iconMap[data.iconName] : undefined
const tooltip = tooltipOverride ?? data?.description ?? ''
const colors = colorMap[colorKey] ?? colorMap.info
const [hovered, setHovered] = useState(false)
const padding = size === 'sm' ? '3px 10px' : '5px 14px'
const fontSize = size === 'sm' ? '0.7rem' : '0.75rem'
const iconSize = size === 'sm' ? 14 : 16
return (
<Tooltip title={tooltip} arrow placement="top">
<Box
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
sx={{
display: 'inline-flex',
alignItems: 'center',
gap: '6px',
padding: padding,
borderRadius: '999px',
border: `1px solid ${colors.border}`,
backgroundColor: hovered ? colors.bg : 'transparent',
transition: 'all 0.2s cubic-bezier(0.16, 1, 0.3, 1)',
cursor: 'default',
'&:active': {
transform: 'scale(0.97)',
},
}}
>
<Box
sx={{
width: '6px',
height: '6px',
borderRadius: '50%',
backgroundColor: colors.dot,
flexShrink: 0,
}}
/>
<Box
sx={{
color: colors.text,
display: 'flex',
alignItems: 'center',
width: iconSize,
height: iconSize,
}}
>
{icon}
</Box>
<Typography
sx={{
fontSize,
fontWeight: 600,
color: colors.text,
lineHeight: 1.2,
letterSpacing: '-0.01em',
}}
>
{label}
</Typography>
</Box>
</Tooltip>
)
}
@@ -0,0 +1,387 @@
# IP-gate Access Control Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Добавить IP-whitelist на уровне Fastify, ограничивающий доступ к сайту на время разработки.
**Architecture:** Новый плагин `server/src/plugins/ip-gate.js` с `onRequest` хуком. Проверяет `request.ip` против `SITE_ACCESS_IPS` из `.env`. Часть путей (OAuth callbacks, webhook-и) исключены из проверки. При отказе — HTML-страница 403 с информацией о магазине и IP посетителя.
**Tech Stack:** Fastify, Node.js, vitest
---
## Task 1: Plugin — create ip-gate.js
**Files:**
- Create: `server/src/plugins/ip-gate.js`
- [ ] **Step 1: Write the plugin**
```js
const EXCLUDED_PATHS = [
'/api/auth/oauth/vk/callback',
'/api/auth/oauth/yandex/callback',
'/api/webhooks/yookassa',
'/api/admin/notifications/telegram/webhook',
]
function build403Html(ip) {
const safeIp = ip || 'не определён'
return `<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Любимый Креатив — Доступ запрещён</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #faf8f5;
color: #3d322b;
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
padding: 24px;
}
.card {
max-width: 520px;
width: 100%;
background: #fff;
border: 1px solid #e8e0d8;
border-radius: 16px;
padding: 48px 40px;
text-align: center;
box-shadow: 0 2px 16px rgb(0 0 0 / 4%);
}
.card h1 {
font-size: 24px;
font-weight: 600;
letter-spacing: -0.3px;
color: #4a3a2e;
margin-bottom: 8px;
}
.card .tagline {
font-size: 14px;
color: #8c8177;
margin-bottom: 32px;
line-height: 1.5;
}
.card .status {
font-size: 16px;
color: #6b5e52;
margin-bottom: 24px;
line-height: 1.6;
}
.card .ip {
font-size: 12px;
color: #b8a99b;
font-family: 'SF Mono', 'Cascadia Code', 'Fira Code', monospace;
}
</style>
</head>
<body>
<div class="card">
<h1>Любимый Креатив</h1>
<p class="tagline">Изделия ручной работы: вещи с характером и вниманием к деталям</p>
<p class="status">Сайт находится в разработке<br>и скоро будет доступен</p>
<p class="ip">Ваш IP: ${safeIp}</p>
</div>
</body>
</html>`
}
export async function registerIpGate(fastify) {
fastify.addHook('onRequest', async (request, reply) => {
const allowed = process.env.SITE_ACCESS_IPS
if (!allowed) return
const allowedIps = allowed
.split(',')
.map((s) => s.trim())
.filter(Boolean)
if (allowedIps.length === 0) return
const urlPath = request.url.split('?')[0]
if (EXCLUDED_PATHS.includes(urlPath)) return
if (allowedIps.includes(request.ip)) return
return reply.code(403).type('text/html').send(build403Html(request.ip))
})
}
```
- [ ] **Step 2: No tests yet — commit the plugin skeleton**
```bash
git add server/src/plugins/ip-gate.js
git commit -m "feat: add ip-gate plugin with env-based IP whitelist"
```
---
## Task 2: Tests for ip-gate
**Files:**
- Create: `server/src/plugins/__tests__/ip-gate.test.js`
- [ ] **Step 1: Write all tests**
```js
import Fastify from 'fastify'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { registerIpGate } from '../ip-gate.js'
function buildApp() {
const app = Fastify({ logger: false, trustProxy: true })
app.get('/test', async () => ({ ok: true }))
app.get('/api/webhooks/yookassa', async () => ({ ok: true }))
app.get('/api/auth/oauth/vk/callback', async () => ({ ok: true }))
app.get('/api/auth/oauth/yandex/callback', async () => ({ ok: true }))
app.get('/api/admin/notifications/telegram/webhook', async () => ({ ok: true }))
return app
}
describe('registerIpGate', () => {
let app
const originalIps = process.env.SITE_ACCESS_IPS
beforeEach(async () => {
app = buildApp()
await registerIpGate(app)
await app.ready()
})
afterEach(async () => {
await app.close()
process.env.SITE_ACCESS_IPS = originalIps
})
it('пропускает запрос если SITE_ACCESS_IPS не задан', async () => {
delete process.env.SITE_ACCESS_IPS
const res = await app.inject({ method: 'GET', url: '/test', remoteAddress: '1.2.3.4' })
expect(res.statusCode).toBe(200)
expect(res.json()).toEqual({ ok: true })
})
it('пропускает запрос с разрешённого IP', async () => {
process.env.SITE_ACCESS_IPS = '1.2.3.4,5.6.7.8'
const res = await app.inject({ method: 'GET', url: '/test', remoteAddress: '1.2.3.4' })
expect(res.statusCode).toBe(200)
})
it('блокирует запрос с неразрешённого IP (403)', async () => {
process.env.SITE_ACCESS_IPS = '1.2.3.4'
const res = await app.inject({ method: 'GET', url: '/test', remoteAddress: '9.9.9.9' })
expect(res.statusCode).toBe(403)
expect(res.headers['content-type']).toMatch(/text\/html/)
expect(res.body).toContain('Любимый Креатив')
expect(res.body).toContain('9.9.9.9')
})
it('403-страница показывает "не определён" когда IP отсутствует', async () => {
process.env.SITE_ACCESS_IPS = '1.2.3.4'
const res = await app.inject({ method: 'GET', url: '/test' })
expect(res.statusCode).toBe(403)
expect(res.body).toContain('не определён')
})
it('пропускает исключённые пути с любым IP (webhook yookassa)', async () => {
process.env.SITE_ACCESS_IPS = '1.2.3.4'
const res = await app.inject({
method: 'GET',
url: '/api/webhooks/yookassa',
remoteAddress: '9.9.9.9',
})
expect(res.statusCode).toBe(200)
})
it('пропускает исключённые пути с любым IP (vk callback)', async () => {
process.env.SITE_ACCESS_IPS = '1.2.3.4'
const res = await app.inject({
method: 'GET',
url: '/api/auth/oauth/vk/callback',
remoteAddress: '9.9.9.9',
})
expect(res.statusCode).toBe(200)
})
it('пропускает исключённые пути с любым IP (yandex callback)', async () => {
process.env.SITE_ACCESS_IPS = '1.2.3.4'
const res = await app.inject({
method: 'GET',
url: '/api/auth/oauth/yandex/callback',
remoteAddress: '9.9.9.9',
})
expect(res.statusCode).toBe(200)
})
it('пропускает исключённые пути с любым IP (telegram webhook)', async () => {
process.env.SITE_ACCESS_IPS = '1.2.3.4'
const res = await app.inject({
method: 'GET',
url: '/api/admin/notifications/telegram/webhook',
remoteAddress: '9.9.9.9',
})
expect(res.statusCode).toBe(200)
})
it('корректно тримит пробелы в списке IP', async () => {
process.env.SITE_ACCESS_IPS = ' 1.2.3.4 , 5.6.7.8 '
const res = await app.inject({
method: 'GET',
url: '/test',
remoteAddress: '5.6.7.8',
})
expect(res.statusCode).toBe(200)
})
it('пропускает если после трима список IP пуст', async () => {
process.env.SITE_ACCESS_IPS = ' , , '
const res = await app.inject({
method: 'GET',
url: '/test',
remoteAddress: '9.9.9.9',
})
expect(res.statusCode).toBe(200)
})
it('путь с query-параметрами проверяется корректно', async () => {
process.env.SITE_ACCESS_IPS = '1.2.3.4'
const res = await app.inject({
method: 'GET',
url: '/test?foo=bar',
remoteAddress: '9.9.9.9',
})
expect(res.statusCode).toBe(403)
})
it('исключённый путь с query-параметрами тоже пропускается', async () => {
process.env.SITE_ACCESS_IPS = '1.2.3.4'
const res = await app.inject({
method: 'GET',
url: '/api/webhooks/yookassa?foo=bar',
remoteAddress: '9.9.9.9',
})
expect(res.statusCode).toBe(200)
})
})
```
- [ ] **Step 2: Run tests to verify they fail (plugin not registered yet in tests)**
```bash
cd server && npx vitest run src/plugins/__tests__/ip-gate.test.js
```
Expected: all tests pass (plugin is registered in the `beforeEach`).
- [ ] **Step 3: Commit tests**
```bash
git add server/src/plugins/__tests__/ip-gate.test.js
git commit -m "test: add ip-gate access control tests"
```
---
## Task 3: Register plugin in index.js
**Files:**
- Modify: `server/src/index.js`
- [ ] **Step 1: Add import**
Add after the existing plugin imports (after line 20):
```js
import { registerIpGate } from './plugins/ip-gate.js'
```
- [ ] **Step 2: Register plugin before routes**
Add before `registerAuth(fastify)` (before line 92):
```js
await registerIpGate(fastify)
```
- [ ] **Step 3: Verify server starts**
```bash
cd server && node --env-file=.env --eval "
import('./src/index.js').catch(e => { console.error(e.message); process.exit(1) })
"
```
Wait ~5 seconds, then Ctrl+C. Expected: no errors in startup logs.
- [ ] **Step 4: Commit**
```bash
git add server/src/index.js
git commit -m "feat: register ip-gate plugin in server startup"
```
---
## Task 4: Environment variable docs
**Files:**
- Modify: `server/.env.example`
- [ ] **Step 1: Add SITE_ACCESS_IPS to .env.example**
Add after the `CORS_ORIGIN` comment block (after line 18):
```
# Ограничение доступа по IP на время разработки (через запятую). Не задано — защита отключена.
# SITE_ACCESS_IPS=1.2.3.4,5.6.7.8
```
- [ ] **Step 2: Add SITE_ACCESS_IPS to actual .env**
```bash
echo "" >> server/.env
echo "# Ограничение доступа по IP. Раскомментируй и укажи свои IP." >> server/.env
echo "# SITE_ACCESS_IPS=1.2.3.4" >> server/.env
```
- [ ] **Step 3: Commit**
```bash
git add server/.env.example
git commit -m "docs: add SITE_ACCESS_IPS env var to .env.example"
```
---
## Task 5: Final verification
**Files:** none (verification only)
- [ ] **Step 1: Run all server tests**
```bash
cd server && npm test
```
Expected: all tests pass, including ip-gate tests.
- [ ] **Step 2: Run ESLint on new files**
```bash
cd server && npx eslint src/plugins/ip-gate.js src/plugins/__tests__/ip-gate.test.js
```
Expected: no errors.
- [ ] **Step 3: Test end-to-end manually**
1. Set `SITE_ACCESS_IPS=127.0.0.1` in `server/.env`
2. Start server: `cd server && npm run dev`
3. `curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:3333/api/catalog`
Expected: `200` (127.0.0.1 в списке)
4. Test blocked: `curl` from a machine not in the list should get 403
5. Remove `SITE_ACCESS_IPS` and verify all requests pass again
@@ -0,0 +1,227 @@
# Synthetic Email Warning Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Display informational alerts in the personal account when the user's email is synthetic (e.g., `vk_<id>@vk.local`), informing them they cannot receive email notifications.
**Architecture:** Pure client-side utility checks email against known synthetic domains. Two MUI Alert components conditionally render in AuthMethodsSection and NotificationsPage.
**Tech Stack:** TypeScript, React, MUI (Alert component), Vitest
---
### Task 1: Create `isSyntheticEmail` utility
**Files:**
- Create: `client/src/shared/lib/is-synthetic-email.ts`
- Test: `client/src/shared/lib/__tests__/is-synthetic-email.test.ts`
- [ ] **Step 1: Write the failing test**
```ts
// client/src/shared/lib/__tests__/is-synthetic-email.test.ts
import { describe, expect, it } from 'vitest'
import { isSyntheticEmail } from '../is-synthetic-email'
describe('isSyntheticEmail', () => {
it('returns true for vk.local domain', () => {
expect(isSyntheticEmail('vk_12345@vk.local')).toBe(true)
})
it('returns false for real email', () => {
expect(isSyntheticEmail('user@gmail.com')).toBe(false)
})
it('returns false for yandex email', () => {
expect(isSyntheticEmail('user@yandex.ru')).toBe(false)
})
it('returns false for empty string', () => {
expect(isSyntheticEmail('')).toBe(false)
})
it('returns false for email with vk.local as part of username', () => {
expect(isSyntheticEmail('vk.local@gmail.com')).toBe(false)
})
})
```
- [ ] **Step 2: Run test to verify it fails**
```bash
cd client && npx vitest run src/shared/lib/__tests__/is-synthetic-email.test.ts
```
Expected: FAIL with "Cannot find module '../is-synthetic-email'"
- [ ] **Step 3: Write the implementation**
```ts
// client/src/shared/lib/is-synthetic-email.ts
const SYNTHETIC_DOMAINS = ['vk.local']
export function isSyntheticEmail(email: string): boolean {
return SYNTHETIC_DOMAINS.some((domain) => email.endsWith(`@${domain}`))
}
```
- [ ] **Step 4: Run test to verify it passes**
```bash
cd client && npx vitest run src/shared/lib/__tests__/is-synthetic-email.test.ts
```
Expected: All 5 tests PASS
- [ ] **Step 5: Commit**
```bash
cd /mnt/d/my_projects/shop
git add client/src/shared/lib/is-synthetic-email.ts client/src/shared/lib/__tests__/is-synthetic-email.test.ts
git commit -m "feat: add isSyntheticEmail utility for detecting synthetic OAuth emails"
```
---
### Task 2: Add warning to AuthMethodsSection
**Files:**
- Modify: `client/src/pages/me/ui/sections/AuthMethodsSection.tsx`
- [ ] **Step 1: Add import and Alert below email display**
Add import at line 22 (after existing imports):
```ts
import { isSyntheticEmail } from '@/shared/lib/is-synthetic-email'
```
Add Alert block after line 118 (`{user.email}`), before line 120 (`{!verificationUrl && (`):
```tsx
{isSyntheticEmail(user.email) && (
<Alert severity="info" sx={{ mb: 2 }}>
Ваша почта сгенерирована автоматически. Без указания реальной почты вы не сможете получать уведомления о заказах.
</Alert>
)}
```
The full relevant section should look like:
```tsx
<Typography sx={{ mb: 2 }} color="text.secondary">
{user.email}
</Typography>
{isSyntheticEmail(user.email) && (
<Alert severity="info" sx={{ mb: 2 }}>
Ваша почта сгенерирована автоматически. Без указания реальной почты вы не сможете получать уведомления о заказах.
</Alert>
)}
{!verificationUrl && (
```
- [ ] **Step 2: Verify lint passes**
```bash
cd client && npm run lint
```
Expected: No errors
- [ ] **Step 3: Commit**
```bash
cd /mnt/d/my_projects/shop
git add client/src/pages/me/ui/sections/AuthMethodsSection.tsx
git commit -m "feat: show synthetic email warning in AuthMethodsSection"
```
---
### Task 3: Add warning to NotificationsPage
**Files:**
- Modify: `client/src/pages/me/ui/sections/NotificationsPage.tsx`
- [ ] **Step 1: Add import, user access, and Alert at page top**
Add import at line 13 (after existing imports):
```ts
import { $user } from '@/shared/model/auth'
import { useUnit } from 'effector-react'
import { isSyntheticEmail } from '@/shared/lib/is-synthetic-email'
```
Add user hook inside the component, after `const [error, setError] = useState<string | null>(null)` (line 28):
```ts
const user = useUnit($user)
```
Add Alert block after line 64 (after the subtitle Typography), before line 66 (`{error && (`):
```tsx
{user && isSyntheticEmail(user.email) && (
<Alert severity="info" sx={{ mb: 2 }}>
Ваша почта сгенерирована автоматически. Для получения уведомлений укажите реальную почту в настройках профиля.
</Alert>
)}
```
The full relevant section should look like:
```tsx
<Typography color="text.secondary" sx={{ mb: 3 }}>
Настройте, какие уведомления вы хотите получать на почту.
</Typography>
{user && isSyntheticEmail(user.email) && (
<Alert severity="info" sx={{ mb: 2 }}>
Ваша почта сгенерирована автоматически. Для получения уведомлений укажите реальную почту в настройках профиля.
</Alert>
)}
{error && (
```
- [ ] **Step 2: Verify lint passes**
```bash
cd client && npm run lint
```
Expected: No errors
- [ ] **Step 3: Commit**
```bash
cd /mnt/d/my_projects/shop
git add client/src/pages/me/ui/sections/NotificationsPage.tsx
git commit -m "feat: show synthetic email warning in NotificationsPage"
```
---
### Task 4: Final verification
- [ ] **Step 1: Run full client test suite**
```bash
cd client && npm test
```
Expected: All tests PASS
- [ ] **Step 2: Run lint and format check**
```bash
cd client && npm run lint && npm run format:check
```
Expected: No errors
- [ ] **Step 3: Run TypeScript check**
```bash
cd client && npx tsc --noEmit
```
Expected: No errors
@@ -0,0 +1,93 @@
# IP-gate: ограничение доступа на время разработки
## Задача
Сайт доступен на реальном домене (через VPS + NPM + Netbird), но находится в активной разработке/тестировании. Нужно ограничить доступ, не мешая разработке и полному тестированию функционала (включая OAuth и webhook-и).
## Решение
IP-whitelist на уровне Fastify (`onRequest` хук). Только запросы с разрешённых IP проходят. Внешние webhook-и и OAuth callback-и исключены из проверки.
## Конфигурация
### `.env`
```env
SITE_ACCESS_IPS=1.2.3.4,5.6.7.8
```
- Не задана или пуста — защита **отключена**
- IP через запятую, пробелы игнорируются (трим)
- `request.ip` возвращает реальный IP клиента благодаря `trustProxy: true`
## Архитектура
### Новый плагин: `server/src/plugins/ip-gate.js`
Регистрируется в `server/src/index.js` **перед** всеми маршрутами.
```js
fastify.register(async function ipGate(fastify, opts) {
fastify.addHook('onRequest', async (request, reply) => {
// защита выключена
// путь в исключениях
// ip в списке
// иначе 403
})
})
```
### Логика `onRequest`
1. `SITE_ACCESS_IPS` пуст → `return` (пропустить)
2. Путь запроса в списке исключений → `return`
3. `request.ip` есть в `SITE_ACCESS_IPS``return`
4. Иначе → `reply.code(403).type('text/html').send(htmlPage)`
### Исключения
Маршруты, которые должны работать всегда (их вызывают внешние сервисы, а не браузер тестировщика):
| Путь | Причина |
|---|---|
| `/api/auth/oauth/vk/callback` | VK OAuth callback |
| `/api/auth/oauth/yandex/callback` | Yandex OAuth callback |
| `/api/webhooks/yookassa` | YooKassa payment webhook |
| `/api/admin/notifications/telegram/webhook` | Telegram webhook |
Статика (загружается браузером тестировщика, поэтому тоже проверяется):
- `/uploads/*` и `/uploads-resized/*`**не** исключаем, блокируются вместе со всем остальным
### 403-страница
HTML-страница с информацией о магазине и статусе разработки:
- Название: «Любимый Креатив»
- Подзаголовок: «Изделия ручной работы: вещи с характером и вниманием к деталям»
- Сообщение: «Сайт находится в разработке и скоро будет доступен»
- Показывает IP посетителя (чтобы можно было сообщить для добавления в whitelist)
- Минимальная стилизация (чистый HTML + inline CSS, без внешних ресурсов)
## Точки регистрации
В `server/src/index.js`:
```js
// после trustProxy, перед маршрутами
await fastify.register(require('./plugins/ip-gate'))
```
## Тестирование
- **Юнит-тесты**: `server/src/plugins/__tests__/ip-gate.test.js`
- IP в списке → запрос проходит
- IP не в списке → 403
- Путь-исключение → проходит с любым IP
- `SITE_ACCESS_IPS` не задан → защита выключена
- Пробелы в списке IP → корректная работа
## Включение/выключение
- **Включить**: задать `SITE_ACCESS_IPS` в `.env`
- **Выключить**: удалить `SITE_ACCESS_IPS` или оставить пустым
- Перезапуск сервера не требуется если используется `node --watch`
@@ -0,0 +1,91 @@
# Редизайн страницы /info — Спецификация
**Дата:** 2026-05-24
**Страница:** `/info` (Информация для покупателей)
**Подход:** Минимальные правки — обновить контент существующих секций, не меняя структуру страницы.
---
## 1. Секция «Как оформить заказ» (HowToOrderSection)
MUI Stepper с ветвлением на шаге 3. Два сценария: доставка и самовывоз.
### Общие шаги (1–2):
| Шаг | Название | Описание |
|-----|----------|----------|
| 1 | Выберите товары | Найдите нужное в каталоге, добавьте в корзину |
| 2 | Проверьте корзину | Убедитесь, что состав и количество верны |
### Ветвление на шаге 3:
#### Доставка:
| Шаг | Название | Описание |
|-----|----------|----------|
| 3 | Укажите адрес доставки и получателя | Имя, телефон, email, адрес (или добавьте новый в личном кабинете) |
| 4 | Выберите способ доставки | Почта России, Озон ПВЗ, Яндекс ПВЗ, 5Post, WB ПВЗ. Дождитесь расчёта стоимости доставки — мастер скорректирует цену |
| 5 | Оплатите заказ | Онлайн через ЮKassa (карты, СБП) |
| 6 | Получите заказ | Отслеживайте по трек-номеру |
#### Самовывоз:
| Шаг | Название | Описание |
|-----|----------|----------|
| 3 | Подтвердите заказ | Выберите оплату: онлайн через ЮKassa или при получении |
| 4 | Согласуйте время получения | Мастер свяжется с вами, чтобы договориться о времени |
| 5 | Получите заказ | Адрес: ул. Мира, 34 (ссылка на /about — карта) |
### Финальный шаг (после получения):
После получения заказа можно оставить отзыв.
---
## 2. Секция «Доставка» (DeliverySection)
Две карточки:
### Самовывоз
- Бесплатно
- Адрес: 618909, Россия, Пермский край, Лысьвенский муниципальный округ, Лысьва, улица Мира, 34
- Ссылка на /about (карта)
- Перед визитом согласуем время — чтобы заказ точно был готов к выдаче
### Доставка по России
Перечислить все 5 служб:
- Почта России
- Озон доставка (пункт выдачи)
- Яндекс доставка (пункт выдачи)
- 5Post (пункт выдачи)
- WB доставка (пункт выдачи)
- Каждому заказу присваивается трек-номер для отслеживания
- Стоимость рассчитывается по тарифу перевозчика, мастер скорректирует цену после оформления
---
## 3. Секция «Оплата» (PaymentSection)
Два способа:
| Способ | Описание |
|--------|----------|
| Онлайн-оплата через ЮKassa (карты, СБП) | Оплата после подтверждения заказа мастером. Перенаправление на защищённую платёжную страницу ЮKassa |
| Оплата при получении | Наличными или картой при самовывозе |
Примечание: оплата происходит после подтверждения заказа мастером.
---
## 4. Секция «Возврат» (ReturnsSection)
Без изменений — текущий контент остаётся.
---
## Файлы для изменения
| Файл | Что менять |
|------|-----------|
| `client/src/pages/info/ui/sections/HowToOrderSection.tsx` | Переписать stepper с ветвлением |
| `client/src/pages/info/ui/sections/DeliverySection.tsx` | Перечислить carriers, добавить ссылку на /about |
| `client/src/pages/info/ui/sections/PaymentSection.tsx` | Заменить «Банковская карта онлайн» на «ЮKassa (карты, СБП)» |
@@ -0,0 +1,74 @@
# Synthetic Email Warning Design
**Date:** 2026-05-24
**Status:** Draft
## Problem
When users authenticate via VK without providing a real email, the server generates a synthetic email (`vk_<userId>@vk.local`). These users cannot receive email notifications, but there is no indication of this in the personal account (LK).
## Solution
Display an informational MUI Alert in two places when the user's email matches a known synthetic domain:
1. **AuthMethodsSection** — where the current email is displayed
2. **NotificationsPage** — where notification settings are managed
## Architecture
### Email Utility
New file: `client/src/shared/lib/email-utils.ts`
```ts
const SYNTHETIC_DOMAINS = ['vk.local']
export function isSyntheticEmail(email: string): boolean {
return SYNTHETIC_DOMAINS.some(domain => email.endsWith(`@${domain}`))
}
```
This is a pure client-side check. The `@vk.local` domain is internal-only — real users cannot register with it.
### AuthMethodsSection
Add MUI Alert below the email display:
```tsx
{isSyntheticEmail(user.email) && (
<Alert severity="info">
Ваша почта сгенерирована автоматически. Без указания реальной почты вы не сможете получать уведомления о заказах.
</Alert>
)}
```
### NotificationsPage
Add MUI Alert at the top of the page:
```tsx
{isSyntheticEmail(user.email) && (
<Alert severity="info">
Ваша почта сгенерирована автоматически. Для получения уведомлений укажите реальную почту в настройках профиля.
</Alert>
)}
```
## Data Flow
```
user.email (from auth model)
→ isSyntheticEmail() check
→ true → render Alert
→ false → no change
```
## Error Handling
- No errors possible — pure string comparison
- If `user.email` is undefined/null, the check returns `false` (safe)
## Testing
- Unit test for `isSyntheticEmail()` with real emails, synthetic emails, edge cases
- No component tests needed — trivial conditional rendering
+3
View File
@@ -17,6 +17,9 @@ JWT_SECRET=замените-на-секрет-jwt
# Разрешённый Origin фронта (через запятую при нескольких)
# CORS_ORIGIN=http://127.0.0.1:5173
# Ограничение доступа по IP на время разработки (через запятую). Не задано — защита отключена.
# SITE_ACCESS_IPS=1.2.3.4,5.6.7.8
# Публичные URL для OAuth redirect (локально обычно так):
SERVER_PUBLIC_URL=http://127.0.0.1:3333
CLIENT_PUBLIC_URL=http://127.0.0.1:5173
Binary file not shown.
+2
View File
@@ -18,6 +18,7 @@ import { createNotificationQueue } from './lib/notifications/queue.js'
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 { registerApiRoutes } from './routes/api.js'
import { registerOAuthSocialRoutes } from './routes/oauth-social.js'
import { registerUploadsResized } from './routes/uploads-resized.js'
@@ -89,6 +90,7 @@ const notificationQueue = createNotificationQueue()
fastify.decorate('eventBus', eventBus)
fastify.decorate('notificationQueue', notificationQueue)
await registerIpGate(fastify)
registerAuth(fastify)
await registerUserAddressRoutes(fastify)
await registerUserCartRoutes(fastify)
@@ -10,10 +10,10 @@ export function renderOrderStatusChangedTg({ orderId, oldStatus, newStatus }) {
DRAFT: 'Черновик',
PENDING_PAYMENT: 'Ожидает оплаты',
PAID: 'Оплачен',
IN_PROGRESS: 'В работе',
IN_PROGRESS: 'Подготовка к отправке',
READY_FOR_PICKUP: 'Готов к выдаче',
SHIPPED: 'Отправлен',
DONE: 'Выполнен',
DONE: 'Завершён',
CANCELLED: 'Отменён',
}
return `🔄 Заказ #${orderId.slice(0, 8)}\n${labels[oldStatus] || oldStatus} → <b>${labels[newStatus] || newStatus}</b>`
@@ -0,0 +1,175 @@
import Fastify from 'fastify'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { build403Html, registerIpGate } from '../ip-gate.js'
function buildApp() {
const app = Fastify({ logger: false, trustProxy: true })
app.get('/test', async () => ({ ok: true }))
app.get('/api/webhooks/yookassa', async () => ({ ok: true }))
app.get('/api/auth/oauth/vk/callback', async () => ({ ok: true }))
app.get('/api/auth/oauth/yandex/callback', async () => ({ ok: true }))
app.get('/api/admin/notifications/telegram/webhook', async () => ({ ok: true }))
return app
}
describe('registerIpGate', () => {
let app
const originalIps = process.env.SITE_ACCESS_IPS
beforeEach(async () => {
app = buildApp()
await registerIpGate(app)
await app.ready()
})
afterEach(async () => {
await app.close()
if (originalIps === undefined) {
delete process.env.SITE_ACCESS_IPS
} else {
process.env.SITE_ACCESS_IPS = originalIps
}
})
it('пропускает запрос если SITE_ACCESS_IPS не задан', async () => {
delete process.env.SITE_ACCESS_IPS
const res = await app.inject({ method: 'GET', url: '/test', remoteAddress: '1.2.3.4' })
expect(res.statusCode).toBe(200)
expect(res.json()).toEqual({ ok: true })
})
it('пропускает запрос с разрешённого IP', async () => {
process.env.SITE_ACCESS_IPS = '1.2.3.4,5.6.7.8'
const res = await app.inject({ method: 'GET', url: '/test', remoteAddress: '1.2.3.4' })
expect(res.statusCode).toBe(200)
})
it('пропускает запрос с IPv6-mapped разрешённого IP', async () => {
process.env.SITE_ACCESS_IPS = '1.2.3.4'
const res = await app.inject({ method: 'GET', url: '/test', remoteAddress: '::ffff:1.2.3.4' })
expect(res.statusCode).toBe(200)
})
it('блокирует запрос с неразрешённого IP (403)', async () => {
process.env.SITE_ACCESS_IPS = '1.2.3.4'
const res = await app.inject({ method: 'GET', url: '/test', remoteAddress: '9.9.9.9' })
expect(res.statusCode).toBe(403)
expect(res.headers['content-type']).toMatch(/text\/html/)
expect(res.body).toContain('Любимый Креатив')
expect(res.body).toContain('9.9.9.9')
})
it('build403Html показывает "не определён" когда IP не передан', () => {
const html = build403Html()
expect(html).toContain('не определён')
expect(html).toContain('Любимый Креатив')
})
it('build403Html показывает переданный IP', () => {
const html = build403Html('9.9.9.9')
expect(html).toContain('9.9.9.9')
expect(html).not.toContain('не определён')
})
it('build403Html с пустой строкой показывает "не определён"', () => {
const html = build403Html('')
expect(html).toContain('не определён')
})
it('403-страница показывает IP по умолчанию (127.0.0.1) когда remoteAddress не указан', async () => {
process.env.SITE_ACCESS_IPS = '1.2.3.4'
const res = await app.inject({ method: 'GET', url: '/test' })
expect(res.statusCode).toBe(403)
expect(res.body).toContain('127.0.0.1')
})
it('пропускает исключённые пути с любым IP (webhook yookassa)', async () => {
process.env.SITE_ACCESS_IPS = '1.2.3.4'
const res = await app.inject({
method: 'GET',
url: '/api/webhooks/yookassa',
remoteAddress: '9.9.9.9',
})
expect(res.statusCode).toBe(200)
})
it('пропускает исключённые пути с любым IP (vk callback)', async () => {
process.env.SITE_ACCESS_IPS = '1.2.3.4'
const res = await app.inject({
method: 'GET',
url: '/api/auth/oauth/vk/callback',
remoteAddress: '9.9.9.9',
})
expect(res.statusCode).toBe(200)
})
it('пропускает исключённые пути с любым IP (yandex callback)', async () => {
process.env.SITE_ACCESS_IPS = '1.2.3.4'
const res = await app.inject({
method: 'GET',
url: '/api/auth/oauth/yandex/callback',
remoteAddress: '9.9.9.9',
})
expect(res.statusCode).toBe(200)
})
it('пропускает исключённые пути с любым IP (telegram webhook)', async () => {
process.env.SITE_ACCESS_IPS = '1.2.3.4'
const res = await app.inject({
method: 'GET',
url: '/api/admin/notifications/telegram/webhook',
remoteAddress: '9.9.9.9',
})
expect(res.statusCode).toBe(200)
})
it('корректно тримит пробелы в списке IP', async () => {
process.env.SITE_ACCESS_IPS = ' 1.2.3.4 , 5.6.7.8 '
const res = await app.inject({
method: 'GET',
url: '/test',
remoteAddress: '5.6.7.8',
})
expect(res.statusCode).toBe(200)
})
it('нормализует IPv6-mapped адреса в whitelist', async () => {
process.env.SITE_ACCESS_IPS = '::ffff:1.2.3.4'
const res = await app.inject({
method: 'GET',
url: '/test',
remoteAddress: '1.2.3.4',
})
expect(res.statusCode).toBe(200)
})
it('пропускает если после трима список IP пуст', async () => {
process.env.SITE_ACCESS_IPS = ' , , '
const res = await app.inject({
method: 'GET',
url: '/test',
remoteAddress: '9.9.9.9',
})
expect(res.statusCode).toBe(200)
})
it('путь с query-параметрами проверяется корректно', async () => {
process.env.SITE_ACCESS_IPS = '1.2.3.4'
const res = await app.inject({
method: 'GET',
url: '/test?foo=bar',
remoteAddress: '9.9.9.9',
})
expect(res.statusCode).toBe(403)
})
it('исключённый путь с query-параметрами тоже пропускается', async () => {
process.env.SITE_ACCESS_IPS = '1.2.3.4'
const res = await app.inject({
method: 'GET',
url: '/api/webhooks/yookassa?foo=bar',
remoteAddress: '9.9.9.9',
})
expect(res.statusCode).toBe(200)
})
})
+102
View File
@@ -0,0 +1,102 @@
const EXCLUDED_PATHS = [
'/api/auth/oauth/vk/callback',
'/api/auth/oauth/yandex/callback',
'/api/webhooks/yookassa',
'/api/admin/notifications/telegram/webhook',
]
function normalizeIp(ip) {
if (ip && ip.startsWith('::ffff:')) {
return ip.slice(7)
}
return ip
}
export function build403Html(ip) {
const safeIp = ip || 'не определён'
return `<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Любимый Креатив</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #faf8f5;
color: #3d322b;
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
padding: 24px;
}
.card {
max-width: 520px;
width: 100%;
background: #fff;
border: 1px solid #e8e0d8;
border-radius: 16px;
padding: 48px 40px;
text-align: center;
box-shadow: 0 2px 16px rgb(0 0 0 / 4%);
}
.card h1 {
font-size: 24px;
font-weight: 600;
letter-spacing: -0.3px;
color: #4a3a2e;
margin-bottom: 8px;
}
.card .tagline {
font-size: 14px;
color: #8c8177;
margin-bottom: 32px;
line-height: 1.5;
}
.card .status {
font-size: 16px;
color: #6b5e52;
margin-bottom: 24px;
line-height: 1.6;
}
.card .ip {
font-size: 12px;
color: #b8a99b;
font-family: 'SF Mono', 'Cascadia Code', 'Fira Code', monospace;
}
</style>
</head>
<body>
<div class="card">
<h1>Любимый Креатив</h1>
<p class="tagline">Изделия ручной работы: вещи с характером и вниманием к деталям</p>
<p class="status">Сайт находится в разработке и скоро будет доступен</p>
<p class="ip">Ваш IP: ${safeIp}</p>
</div>
</body>
</html>`
}
export async function registerIpGate(fastify) {
fastify.addHook('onRequest', async (request, reply) => {
const allowed = process.env.SITE_ACCESS_IPS
if (!allowed) return
const allowedIps = allowed
.split(',')
.map((s) => normalizeIp(s.trim()))
.filter(Boolean)
if (allowedIps.length === 0) return
const urlPath = request.url.split('?')[0]
if (EXCLUDED_PATHS.includes(urlPath)) return
if (allowedIps.includes(normalizeIp(request.ip))) return
return reply.code(403).type('text/html').send(build403Html(request.ip))
})
}
+14
View File
@@ -204,4 +204,18 @@ export async function registerAuthRoutes(fastify) {
})
return { user: mapUserForClient(updated) }
})
fastify.delete('/api/me', { preHandler: [fastify.authenticate] }, async (request, reply) => {
const userId = request.user.sub
const ACTIVE_STATUSES = ['DRAFT', 'PENDING_PAYMENT', 'PAID', 'IN_PROGRESS', 'SHIPPED', 'READY_FOR_PICKUP']
const activeOrders = await prisma.order.findMany({
where: { userId, status: { in: ACTIVE_STATUSES } },
select: { id: true },
})
await prisma.user.delete({ where: { id: userId } })
return { ok: true, activeOrderIds: activeOrders.map((o) => o.id) }
})
}
+22 -22
View File
@@ -1,13 +1,13 @@
export const ORDER_STATUSES = Object.freeze([
'DRAFT',
'PENDING_PAYMENT',
'PAID',
'IN_PROGRESS',
'SHIPPED',
'READY_FOR_PICKUP',
'DONE',
'CANCELLED',
])
"DRAFT",
"PENDING_PAYMENT",
"PAID",
"IN_PROGRESS",
"SHIPPED",
"READY_FOR_PICKUP",
"DONE",
"CANCELLED",
]);
/**
* Допустимые переходы статусов, доступные админу.
@@ -15,24 +15,24 @@ export const ORDER_STATUSES = Object.freeze([
* Для IN_PROGRESS: объект с ключами по deliveryType.
*/
export const ADMIN_ORDER_TRANSITIONS = Object.freeze({
DRAFT: ['PENDING_PAYMENT', 'CANCELLED'],
PENDING_PAYMENT: ['PAID', 'CANCELLED'],
PAID: ['IN_PROGRESS', 'CANCELLED'],
DRAFT: ["PENDING_PAYMENT", "CANCELLED"],
PENDING_PAYMENT: ["PAID", "CANCELLED"],
PAID: ["IN_PROGRESS", "CANCELLED"],
IN_PROGRESS: Object.freeze({
delivery: ['SHIPPED', 'CANCELLED'],
pickup: ['READY_FOR_PICKUP', 'CANCELLED'],
delivery: ["SHIPPED", "CANCELLED"],
pickup: ["READY_FOR_PICKUP", "CANCELLED"],
}),
})
});
export function getNextAdminStatuses(from, deliveryType) {
const transition = ADMIN_ORDER_TRANSITIONS[from]
if (!transition) return []
if (Array.isArray(transition)) return [...transition]
return transition[deliveryType] ? [...transition[deliveryType]] : []
const transition = ADMIN_ORDER_TRANSITIONS[from];
if (!transition) return [];
if (Array.isArray(transition)) return [...transition];
return transition[deliveryType] ? [...transition[deliveryType]] : [];
}
export function canTransitionAdminOrderStatus(order, next) {
const from = order.status
if (from === next) return true
return getNextAdminStatuses(from, order.deliveryType).includes(next)
const from = order.status;
if (from === next) return true;
return getNextAdminStatuses(from, order.deliveryType).includes(next);
}
@@ -0,0 +1,157 @@
---
title: "Требования Роскомнадзора к сайтам 2026: чек-лист для бизнеса"
source: "https://www.klerk.ru/blogs/roskom24/650389/#chapter--4-cookie-uvedomlenie-i-politika"
author:
- "[[Закон и бизнес | Онлайн услуги 24]]"
published: 2025-06-10
created: 2026-05-23
description: "С 30 мая 2025 года в России вступили в силу изменения в законодательство о персональных данных. Для бизнеса — это не просто очередная «формальность», а вопрос безопасности и выживания."
tags:
- "clippings"
---
Роскомнадзор усилил контроль за сайтами, включая автоматическую проверку с использованием ИИ. Теперь даже незначительные, по мнению бизнеса, нарушения могут привести **к штрафам до 18 миллионов рублей или блокировке сайта**.
Если у вас есть сайт, вы — оператор персональных данных. А значит, обязаны соблюдать [ФЗ-152 «О персональных данных»](https://www.klerk.ru/cdoc/view/federalnyj-zakon-ot-27072006-no-152-fz-o-personalnyh-dannyh/). Даже если вы ИП, самозанятый или оказываете услуги онлайн. Ниже — актуальный чек-лист на 2026 год, который поможет не попасть под штраф и не дать конкурентам «закопать» ваш бизнес через жалобу в Роскомнадзор.
## ✅ 1. Политика обработки персональных данных на сайте
### Что это
Официальный документ, размещенный на сайте (*обычно в подвале*), который описывает:
- какие данные вы собираете;
- как их обрабатываете;
- кому передаете и на каких основаниях.
### Требования 2026
- Обязательно актуальная редакция.
- Полный перечень обрабатываемых данных (*имя, email, телефон, cookies и т.д.*).
- Указание целей и оснований обработки.
- Контактные данные оператора.
- Ссылки на формы согласия и порядок отзыва.
📌 **Ошибка №1** — скачать шаблон и забыть про него. Политика должна соответствовать именно вашему бизнесу и интеграциям на сайте.
## ✅ 2. Согласие на обработку персональных данных
### Где должно быть
- во всех формах на сайте: заявки, обратная связь, регистрация, квизы, покупка, консультации;
- при подписке на рассылку;
- в онлайн-чате, если сохраняются данные.
### Требования 2026
- Согласие должно быть **добровольным, конкретным, информированным и однозначным**.
- Включает: ФИО, перечень данных, цель обработки, срок хранения, право отзыва.
- Отдельное согласие на передачу данных третьим лицам (*например, CRM-системам*).
- Техническая реализация: **отдельный чекбокс с обязательной активацией**, а не просто фраза «нажимая кнопку, вы соглашаетесь».
📌 **Ошибка №2** — отсутствие чекбокса или невидимый текст, отсутствие Log файлов позволяющих доказать получение согласия на обработку персональных данных от пользователя сайта.
## ✅ 3. Уведомление Роскомнадзора о начале обработки персональных данных
### Кто обязан
Любой, кто собирает ПДн через сайт — даже ИП и самозанятые.
### Требования 2026
- До начала обработки нужно подать уведомление через портал Роскомнадзора.
- Указать все сведения: цели, способы обработки, меры безопасности, перечень используемых информационных систем.
- Отдельно — факт трансграничной передачи, если используете иностранные сервисы.
📌 **Ошибка №3** — не уведомили Роскомнадзор, потому что «сайт только визитка». Даже форма обратной связи — уже обработка ПДн.
## ✅ 5. Юридическая информация в подвале сайта
Что должно быть ([*ч. 2 ст. 10 ФЗ № 149-ФЗ «Об информации, информационных технологиях и о защите информации»*](https://www.klerk.ru/cdoc/view/federalnyj-zakon-ot-27072006-no-149-fz-ob-informacii-informacionnyh-tehnologiah-i-o-zasite-informacii/stata-10-rasprostranenie-informacii-ili-predostavlenie-informacii/#p_64595)):
- Полное наименование владельца сайта.
- Адрес места нахождения.
- Актуальные контактные данные.
### Почему это важно
Размещение недостоверных сведений может привести к привлечению к административной ответственности по [статье 14.4 КоАП](https://www.klerk.ru/cdoc/view/kodeks-ob-administrativnyh-pravonaruseniah-koap-rf/stata-144-prodaza-tovarov-vypolnenie-rabot-libo-okazanie-naseleniu-uslug-nenadlezasego-kacestva-ili-s-naruseniem-ustanovlennyh-zakonodatelstvom-rossijskoj-federacii-trebovanij/) (*нарушение законодательства о рекламе*), а также к гражданско-правовой ответственности за причиненный ущерб.
📌 **Ошибка №5** — указание только бренда или торговой марки без юр. лица.
## ✅ 6. Российский хостинг и запрет трансграничной передачи
### Суть
Использование **иностранных серверов и облаков** приравнивается к трансграничной передаче ПДн.
### Требования
- Хостинг сайта — только на серверах, физически размещенных в России.
- Подтверждение от хостинг-провайдера.
- Запрет на хранение ПДн в Google Drive, Notion, Dropbox и т.д.
- Meta <sup>1</sup> Pixel, Google Analytics, сайты размещенные на иностранных хостингах — повод для штрафа, если не отражены в документах.
📌 **Ошибка №7** — сайт на Tilda или REG.RU, но физически размещен в Европе. Это нарушение.
Материалы по теме[Топ вопросов и ответов про работу с персональными данными в 2025 году](https://www.klerk.ru/buh/articles/660617/?utm_source=klerk&utm_medium=article&utm_campaign=recommendation&utm_content=blocklinks&utm_term=650389)
[
Главные изменения в законе о персональных данных с 1 сентября 2025
](https://www.klerk.ru/buh/articles/660820/?utm_source=klerk&utm_medium=article&utm_campaign=recommendation&utm_content=blocklinks&utm_term=650389)[
152-ФЗ о персональных данных: требования закона для бизнеса в 2026 году
](https://www.klerk.ru/blogs/roskom24/674017/?utm_source=klerk&utm_medium=article&utm_campaign=recommendation&utm_content=blocklinks&utm_term=650389)
## ✅ 8. Проверка Роскомнадзора: автоматизированная, быстрая, без предупреждения
### Как работает
- Искусственный интеллект сканирует сайт 24/7.
- Проверяет не только текст, но и **код сайта, скрытые скрипты, cookie, формы**.
- Не требует предварительного уведомления.
- Фиксация нарушений → **предписание или моментальный штраф**.
📌 **Важно**: проверить сайт глазами — недостаточно. Нарушения могут быть «внутри» — в интеграциях, скриптах и DOM-структуре.
## ⚠️ Жалобы конкурентов — новый инструмент давления
- Предприниматели уже используют жалобы в Роскомнадзор как способ атаковать конкурентов.
- Роскомнадзор обязан реагировать на любую жалобу, даже анонимную.
- Уже есть случаи блокировки сайтов и штрафов по жалобе «доброжелателей».
📌 **Вывод** — не дать конкурентам повода. Даже мелкая недоработка — риск для бизнеса.
## 🛠 Что делать бизнесу уже сейчас
### Шаги
1. Провести аудит сайта (*юридический + технический*).
2. Проверить наличие и актуальность всех обязательных документов.
3. Зарегистрироваться в Роскомнадзоре как оператор ПДн.
4. Обновить Политику ПДн и Cookie-согласие.
5. Убедиться, что сайт размещен на российских серверах.
6. Устранить трансграничные риски (*зарубежные сервисы, скрипты*).
7. Назначить ответственного за ПДн внутри компании.
## 💼 Как мы можем помочь
Сервис [«Роском 24»](https://roskom24.ru/?utm_source=klerkru-blog&utm_medium=trebovaniya_roskomnadzora_k_saitam) — это команда юристов и технических специалистов, которые:
- Проводят аудит сайта на соответствие [ФЗ-152](https://www.klerk.ru/cdoc/view/federalnyj-zakon-ot-27072006-no-152-fz-o-personalnyh-dannyh/).
- Подготавливают полный комплект документов.
- Помогают зарегистрироваться в Роскомнадзоре.
- Настраивают cookie-уведомления и формы согласия.
- Защищают бизнес от штрафов и блокировок.
## 📌 Итог
В 2026 году **невозможно заниматься бизнесом онлайн и игнорировать закон о персональных данных**. Сайт — это уже зона юридической ответственности. И чем раньше вы проведете аудит и приведете все в порядок, тем больше шансов избежать штрафов, блокировок и атак конкурентов.
👉 Проверьте свой сайт прямо сейчас. Или [доверьтесь профессионалам](https://roskom24.ru/?utm_source=klerkru-blog&utm_medium=trebovaniya_roskomnadzora_k_saitam).
*Реклама: ООО «ОНЛАЙН УСЛУГИ 24», ИНН 7751227590, erid: 2W5zFJLb1J8*
1. Деятельность компании Meta Platforms Inc. (Facebook и Instagram) на территории РФ запрещена