Merge branch 'site-fixes'
This commit is contained in:
@@ -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
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 }}>
|
||||
|
||||
@@ -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 }}>
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
@@ -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.
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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))
|
||||
})
|
||||
}
|
||||
@@ -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) }
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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) на территории РФ запрещена
|
||||
Reference in New Issue
Block a user