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`
|
- 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`
|
- 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
|
## Notable quirks
|
||||||
|
|
||||||
- `.env` is gitignored. Copy `.env.example` to `.env` for local dev.
|
- `.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 { AppHeader } from '@/app/layout/AppHeader'
|
||||||
import vkLogoSrc from '@/shared/assets/vk-logo.svg'
|
import vkLogoSrc from '@/shared/assets/vk-logo.svg'
|
||||||
import { STORE_EMAIL, STORE_NAME, STORE_PHONE, VK_URL } from '@/shared/config'
|
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 { ScrollOnNavigate } from '@/shared/ui/ScrollOnNavigate'
|
||||||
import { ScrollToTop } from '@/shared/ui/ScrollToTop'
|
import { ScrollToTop } from '@/shared/ui/ScrollToTop'
|
||||||
|
|
||||||
@@ -118,6 +119,7 @@ export function MainLayout({ children }: PropsWithChildren) {
|
|||||||
</Box>
|
</Box>
|
||||||
</Container>
|
</Container>
|
||||||
</Box>
|
</Box>
|
||||||
|
<CookieConsentBanner />
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -128,6 +128,7 @@ export function AddressMapPicker(props: {
|
|||||||
const lat = Number(r.lat)
|
const lat = Number(r.lat)
|
||||||
const lng = Number(r.lon)
|
const lng = Number(r.lon)
|
||||||
if (!Number.isFinite(lat) || !Number.isFinite(lng)) return
|
if (!Number.isFinite(lat) || !Number.isFinite(lng)) return
|
||||||
|
mapRef.current?.flyTo({ center: [lng, lat], zoom: 13, duration: 800 })
|
||||||
void pick({ lat, lng })
|
void pick({ lat, lng })
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
import Button from '@mui/material/Button'
|
import Button from '@mui/material/Button'
|
||||||
import InputAdornment from '@mui/material/InputAdornment'
|
import InputAdornment from '@mui/material/InputAdornment'
|
||||||
|
import Link from '@mui/material/Link'
|
||||||
import Stack from '@mui/material/Stack'
|
import Stack from '@mui/material/Stack'
|
||||||
import TextField from '@mui/material/TextField'
|
import TextField from '@mui/material/TextField'
|
||||||
|
import Typography from '@mui/material/Typography'
|
||||||
import { useMutation } from '@tanstack/react-query'
|
import { useMutation } from '@tanstack/react-query'
|
||||||
import { Mail } from 'lucide-react'
|
import { Mail } from 'lucide-react'
|
||||||
import { useForm } from 'react-hook-form'
|
import { useForm } from 'react-hook-form'
|
||||||
|
import { Link as RouterLink } from 'react-router-dom'
|
||||||
import { apiClient } from '@/shared/api/client'
|
import { apiClient } from '@/shared/api/client'
|
||||||
import { getApiErrorMessage } from '@/shared/lib/get-api-error-message'
|
import { getApiErrorMessage } from '@/shared/lib/get-api-error-message'
|
||||||
import { tokenSet } from '@/shared/model/auth'
|
import { tokenSet } from '@/shared/model/auth'
|
||||||
@@ -95,6 +98,18 @@ export function AuthCodeForm({ onSuccess }: Props) {
|
|||||||
sx={{ display: 'none' }}
|
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>
|
</Stack>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
import Button from '@mui/material/Button'
|
import Button from '@mui/material/Button'
|
||||||
import InputAdornment from '@mui/material/InputAdornment'
|
import InputAdornment from '@mui/material/InputAdornment'
|
||||||
|
import Link from '@mui/material/Link'
|
||||||
import Stack from '@mui/material/Stack'
|
import Stack from '@mui/material/Stack'
|
||||||
import TextField from '@mui/material/TextField'
|
import TextField from '@mui/material/TextField'
|
||||||
|
import Typography from '@mui/material/Typography'
|
||||||
import { useMutation } from '@tanstack/react-query'
|
import { useMutation } from '@tanstack/react-query'
|
||||||
import { Lock, Mail } from 'lucide-react'
|
import { Lock, Mail } from 'lucide-react'
|
||||||
import { useForm } from 'react-hook-form'
|
import { useForm } from 'react-hook-form'
|
||||||
|
import { Link as RouterLink } from 'react-router-dom'
|
||||||
import { apiClient } from '@/shared/api/client'
|
import { apiClient } from '@/shared/api/client'
|
||||||
import { getApiErrorMessage } from '@/shared/lib/get-api-error-message'
|
import { getApiErrorMessage } from '@/shared/lib/get-api-error-message'
|
||||||
import { tokenSet } from '@/shared/model/auth'
|
import { tokenSet } from '@/shared/model/auth'
|
||||||
@@ -185,6 +188,18 @@ export function AuthPasswordForm({ isRegister, onRegisterChange, onSuccess }: Pr
|
|||||||
sx={{ display: 'none' }}
|
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>
|
</Stack>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,12 +19,7 @@ function ReviewItem({ rv }: { rv: PublicProductReviewItem }) {
|
|||||||
<Paper variant="outlined" sx={{ p: 1.5, borderRadius: 2 }}>
|
<Paper variant="outlined" sx={{ p: 1.5, borderRadius: 2 }}>
|
||||||
<Stack spacing={0.75}>
|
<Stack spacing={0.75}>
|
||||||
<Stack direction="row" spacing={1.5} sx={{ alignItems: 'center' }}>
|
<Stack direction="row" spacing={1.5} sx={{ alignItems: 'center' }}>
|
||||||
<UserAvatar
|
<UserAvatar userId={rv.authorId} avatarUrl={rv.authorAvatar} avatarStyle={rv.authorAvatarStyle} size={32} />
|
||||||
userId={rv.authorId}
|
|
||||||
avatarUrl={rv.authorAvatar}
|
|
||||||
avatarStyle={rv.authorAvatarStyle}
|
|
||||||
size={32}
|
|
||||||
/>
|
|
||||||
<Box sx={{ flexGrow: 1 }}>
|
<Box sx={{ flexGrow: 1 }}>
|
||||||
<Typography sx={{ fontWeight: 700 }}>{rv.authorDisplay}</Typography>
|
<Typography sx={{ fontWeight: 700 }}>{rv.authorDisplay}</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -35,12 +35,8 @@ export function AboutPage() {
|
|||||||
<Stack spacing={3}>
|
<Stack spacing={3}>
|
||||||
<Paper variant="outlined" sx={{ p: 2, borderRadius: 2 }}>
|
<Paper variant="outlined" sx={{ p: 2, borderRadius: 2 }}>
|
||||||
<Typography variant="h6" gutterBottom>
|
<Typography variant="h6" gutterBottom>
|
||||||
Контакты и самовывоз
|
Контакты
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography sx={{ mb: 1 }}>
|
|
||||||
Забрать заказ можно по адресу самовывоза (координаты указаны на карте ниже):
|
|
||||||
</Typography>
|
|
||||||
<Typography sx={{ whiteSpace: 'pre-wrap', fontWeight: 600 }}>{PICKUP_ADDRESS_FULL}</Typography>
|
|
||||||
<Typography variant="body2" sx={{ mt: 1 }}>
|
<Typography variant="body2" sx={{ mt: 1 }}>
|
||||||
Email:{' '}
|
Email:{' '}
|
||||||
<Link href={`mailto:${STORE_EMAIL}`} underline="hover">
|
<Link href={`mailto:${STORE_EMAIL}`} underline="hover">
|
||||||
@@ -58,6 +54,8 @@ export function AboutPage() {
|
|||||||
ВКонтакте
|
ВКонтакте
|
||||||
</Link>
|
</Link>
|
||||||
</Typography>
|
</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 color="text.secondary" variant="body2" sx={{ mt: 1 }}>
|
||||||
Перед визитом мы свяжемся с вами и согласуем время — чтобы заказ точно был готов к выдаче.
|
Перед визитом мы свяжемся с вами и согласуем время — чтобы заказ точно был готов к выдаче.
|
||||||
</Typography>
|
</Typography>
|
||||||
@@ -75,7 +73,7 @@ export function AboutPage() {
|
|||||||
>
|
>
|
||||||
<Map
|
<Map
|
||||||
mapLib={maplibregl}
|
mapLib={maplibregl}
|
||||||
initialViewState={{ latitude: lat, longitude: lng, zoom: 16 }}
|
initialViewState={{ latitude: lat, longitude: lng, zoom: 15 }}
|
||||||
style={{ width: '100%', height: 380 }}
|
style={{ width: '100%', height: 380 }}
|
||||||
mapStyle={rasterStyle}
|
mapStyle={rasterStyle}
|
||||||
scrollZoom={false}
|
scrollZoom={false}
|
||||||
|
|||||||
@@ -1,27 +1,68 @@
|
|||||||
import Box from '@mui/material/Box'
|
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 Stack from '@mui/material/Stack'
|
||||||
import Typography from '@mui/material/Typography'
|
import Typography from '@mui/material/Typography'
|
||||||
import { DeliverySection } from './sections/DeliverySection'
|
import { DeliverySection } from './sections/DeliverySection'
|
||||||
import { HowToOrderSection } from './sections/HowToOrderSection'
|
import { HowToOrderSection } from './sections/HowToOrderSection'
|
||||||
|
import { OrderStatusesSection } from './sections/OrderStatusesSection'
|
||||||
import { PaymentSection } from './sections/PaymentSection'
|
import { PaymentSection } from './sections/PaymentSection'
|
||||||
import { ReturnsSection } from './sections/ReturnsSection'
|
import { ReturnsSection } from './sections/ReturnsSection'
|
||||||
|
|
||||||
export function InfoPage() {
|
export function InfoPage() {
|
||||||
return (
|
return (
|
||||||
<Box>
|
<Container maxWidth="lg" sx={{ py: { xs: 4 } }}>
|
||||||
<Typography variant="h4" gutterBottom>
|
{/* 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>
|
||||||
<Typography color="text.secondary" sx={{ mb: 3 }}>
|
<Typography
|
||||||
|
sx={{
|
||||||
|
fontSize: '1rem',
|
||||||
|
color: 'text.secondary',
|
||||||
|
lineHeight: 1.7,
|
||||||
|
maxWidth: '58ch',
|
||||||
|
}}
|
||||||
|
>
|
||||||
Как оформить заказ, как проходит доставка, оплата и другие важные детали.
|
Как оформить заказ, как проходит доставка, оплата и другие важные детали.
|
||||||
</Typography>
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
<Stack spacing={3}>
|
{/* Main content grid */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: { xs: '1fr', lg: '1fr 1fr' },
|
||||||
|
gap: { xs: 6, md: 8 },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Left column */}
|
||||||
|
<Stack spacing={6}>
|
||||||
<HowToOrderSection />
|
<HowToOrderSection />
|
||||||
|
<Divider />
|
||||||
<DeliverySection />
|
<DeliverySection />
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{/* Right column */}
|
||||||
|
<Stack spacing={6}>
|
||||||
<PaymentSection />
|
<PaymentSection />
|
||||||
|
<Divider />
|
||||||
|
<OrderStatusesSection />
|
||||||
|
<Divider />
|
||||||
<ReturnsSection />
|
<ReturnsSection />
|
||||||
</Stack>
|
</Stack>
|
||||||
</Box>
|
</Box>
|
||||||
|
</Container>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,52 +1,172 @@
|
|||||||
import Grid from '@mui/material/Grid'
|
import Box from '@mui/material/Box'
|
||||||
import Paper from '@mui/material/Paper'
|
import Link from '@mui/material/Link'
|
||||||
import Stack from '@mui/material/Stack'
|
import Stack from '@mui/material/Stack'
|
||||||
import Typography from '@mui/material/Typography'
|
import Typography from '@mui/material/Typography'
|
||||||
import { Package, Store } from 'lucide-react'
|
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'
|
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() {
|
export function DeliverySection() {
|
||||||
return (
|
return (
|
||||||
<Paper variant="outlined" sx={{ p: 3, borderRadius: 2 }}>
|
<Box>
|
||||||
<Typography variant="h5" gutterBottom>
|
<Typography
|
||||||
|
variant="h5"
|
||||||
|
sx={{
|
||||||
|
fontWeight: 700,
|
||||||
|
letterSpacing: '-0.03em',
|
||||||
|
lineHeight: 1.15,
|
||||||
|
mb: 3,
|
||||||
|
}}
|
||||||
|
>
|
||||||
Доставка
|
Доставка
|
||||||
</Typography>
|
</Typography>
|
||||||
<Grid container spacing={2}>
|
|
||||||
{deliveries.map((d) => (
|
<Stack
|
||||||
<Grid key={d.title} size={{ xs: 12, sm: 6, md: 4 }}>
|
direction={{ xs: 'column', sm: 'row' }}
|
||||||
<Paper variant="outlined" sx={{ p: 2, borderRadius: 2, height: '100%' }}>
|
spacing={3}
|
||||||
<Stack spacing={1.5}>
|
sx={{
|
||||||
<Stack direction="row" spacing={1} sx={{ alignItems: 'center' }}>
|
p: 3,
|
||||||
{d.icon}
|
borderRadius: '14px',
|
||||||
<Typography variant="h6">{d.title}</Typography>
|
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>
|
</Stack>
|
||||||
{d.lines.map((line, i) => (
|
<Typography
|
||||||
<Typography key={i} variant="body2" color="text.secondary">
|
sx={{
|
||||||
{line}
|
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>
|
</Typography>
|
||||||
))}
|
))}
|
||||||
</Stack>
|
</Stack>
|
||||||
</Paper>
|
<Typography
|
||||||
</Grid>
|
sx={{
|
||||||
))}
|
fontSize: '0.8rem',
|
||||||
</Grid>
|
color: 'text.secondary',
|
||||||
</Paper>
|
lineHeight: 1.65,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Стоимость рассчитывается по тарифу перевозчика. Админ скорректирует цену после оформления заказа.
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,55 +1,233 @@
|
|||||||
import Paper from '@mui/material/Paper'
|
import { useState } from 'react'
|
||||||
import Step from '@mui/material/Step'
|
import Box from '@mui/material/Box'
|
||||||
import StepContent from '@mui/material/StepContent'
|
import Link from '@mui/material/Link'
|
||||||
import StepLabel from '@mui/material/StepLabel'
|
import Stack from '@mui/material/Stack'
|
||||||
import Stepper from '@mui/material/Stepper'
|
import Tab from '@mui/material/Tab'
|
||||||
|
import Tabs from '@mui/material/Tabs'
|
||||||
import Typography from '@mui/material/Typography'
|
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: 'Выберите товары',
|
label: 'Выберите товары',
|
||||||
icon: <ShoppingCart size={20} />,
|
icon: <ShoppingCart size={18} />,
|
||||||
text: 'Найдите нужные изделия в каталоге и добавьте их в корзину. Вы можете выбрать несколько товаров от разных мастеров — все они соберутся в одном заказе.',
|
text: 'Найдите нужные изделия в каталоге и добавьте их в корзину. Вы можете выбрать несколько товаров от разных мастеров — все они соберутся в одном заказе.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Проверьте корзину',
|
label: 'Проверьте корзину',
|
||||||
icon: <ClipboardList size={20} />,
|
icon: <ClipboardList size={18} />,
|
||||||
text: 'Перейдите в корзину и проверьте состав заказа: названия товаров, количество и итоговую сумму. Здесь же можно изменить количество или удалить позиции.',
|
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 (
|
return (
|
||||||
<Paper variant="outlined" sx={{ p: 3, borderRadius: 2 }}>
|
<Box sx={{ display: 'flex', gap: 2.5, pb: isLast ? 0 : 2.5, position: 'relative' }}>
|
||||||
<Typography variant="h5" gutterBottom>
|
<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>
|
||||||
<Stepper orientation="vertical" activeStep={-1}>
|
<Typography
|
||||||
{steps.map((step) => (
|
sx={{
|
||||||
<Step key={step.label} completed={false}>
|
fontSize: '0.8rem',
|
||||||
<StepLabel slots={{ stepIcon: () => step.icon }}>{step.label}</StepLabel>
|
color: 'text.secondary',
|
||||||
<StepContent>
|
lineHeight: 1.65,
|
||||||
<Typography color="text.secondary">{step.text}</Typography>
|
}}
|
||||||
</StepContent>
|
>
|
||||||
</Step>
|
{step.text}
|
||||||
))}
|
</Typography>
|
||||||
</Stepper>
|
</Box>
|
||||||
</Paper>
|
</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 Box from '@mui/material/Box'
|
||||||
import ListItem from '@mui/material/ListItem'
|
import Stack from '@mui/material/Stack'
|
||||||
import ListItemIcon from '@mui/material/ListItemIcon'
|
|
||||||
import ListItemText from '@mui/material/ListItemText'
|
|
||||||
import Paper from '@mui/material/Paper'
|
|
||||||
import Typography from '@mui/material/Typography'
|
import Typography from '@mui/material/Typography'
|
||||||
import { Banknote, CreditCard } from 'lucide-react'
|
import { Banknote, CreditCard } from 'lucide-react'
|
||||||
|
|
||||||
const methods = [
|
const methods = [
|
||||||
{
|
{
|
||||||
icon: <CreditCard size={22} />,
|
icon: <CreditCard size={18} />,
|
||||||
primary: 'Банковская карта онлайн',
|
primary: 'Онлайн-оплата через ЮKassa',
|
||||||
secondary: 'Оплата картой Visa, Mastercard или МИР сразу при оформлении заказа.',
|
secondary: 'Карты, СБП. Оплата после подтверждения заказа админом. Перенаправление на защищённую платёжную страницу.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: <Banknote size={22} />,
|
icon: <Banknote size={18} />,
|
||||||
primary: 'Оплата при получении',
|
primary: 'Оплата при получении',
|
||||||
secondary: 'Оплата наличными или картой при получении заказа.',
|
secondary: 'Наличными или картой при самовывозе.',
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
export function PaymentSection() {
|
export function PaymentSection() {
|
||||||
return (
|
return (
|
||||||
<Paper variant="outlined" sx={{ p: 3, borderRadius: 2 }}>
|
<Box>
|
||||||
<Typography variant="h5" gutterBottom>
|
<Typography
|
||||||
|
variant="h5"
|
||||||
|
sx={{
|
||||||
|
fontWeight: 700,
|
||||||
|
letterSpacing: '-0.03em',
|
||||||
|
lineHeight: 1.15,
|
||||||
|
mb: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
Оплата
|
Оплата
|
||||||
</Typography>
|
</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>
|
</Typography>
|
||||||
<List disablePadding>
|
|
||||||
|
<Stack spacing={2}>
|
||||||
{methods.map((m) => (
|
{methods.map((m) => (
|
||||||
<ListItem key={m.primary} disableGutters>
|
<Stack
|
||||||
<ListItemIcon sx={{ minWidth: 40 }}>{m.icon}</ListItemIcon>
|
key={m.primary}
|
||||||
<ListItemText primary={m.primary} secondary={m.secondary} />
|
direction="row"
|
||||||
</ListItem>
|
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>
|
</Stack>
|
||||||
</Paper>
|
</Box>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,35 +1,67 @@
|
|||||||
import Paper from '@mui/material/Paper'
|
import Box from '@mui/material/Box'
|
||||||
import Stack from '@mui/material/Stack'
|
import Stack from '@mui/material/Stack'
|
||||||
import Typography from '@mui/material/Typography'
|
import Typography from '@mui/material/Typography'
|
||||||
|
|
||||||
export function ReturnsSection() {
|
export function ReturnsSection() {
|
||||||
return (
|
return (
|
||||||
<Paper variant="outlined" sx={{ p: 3, borderRadius: 2 }}>
|
<Box>
|
||||||
<Typography variant="h5" gutterBottom>
|
<Typography
|
||||||
|
variant="h5"
|
||||||
|
sx={{
|
||||||
|
fontWeight: 700,
|
||||||
|
letterSpacing: '-0.03em',
|
||||||
|
lineHeight: 1.15,
|
||||||
|
mb: 3,
|
||||||
|
}}
|
||||||
|
>
|
||||||
Возврат и гарантии
|
Возврат и гарантии
|
||||||
</Typography>
|
</Typography>
|
||||||
<Stack spacing={2}>
|
|
||||||
<Paper variant="outlined" sx={{ p: 2, borderRadius: 2 }}>
|
<Stack spacing={2.5}>
|
||||||
<Typography variant="subtitle1" sx={{ fontWeight: 600 }} gutterBottom>
|
<Box>
|
||||||
|
<Typography
|
||||||
|
sx={{
|
||||||
|
fontWeight: 600,
|
||||||
|
fontSize: '0.875rem',
|
||||||
|
letterSpacing: '-0.01em',
|
||||||
|
mb: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
Возврат
|
Возврат
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="body2" color="text.secondary">
|
<Typography
|
||||||
Если товар не соответствует описанию или имеет производственный дефект, свяжитесь с нами в течение 7 дней
|
sx={{
|
||||||
после получения. Мы заменим изделие на аналогичное или вернём деньги. Возврат товара надлежащего качества
|
fontSize: '0.8rem',
|
||||||
возможен в течение 14 дней, если изделие не было в употреблении и сохранён его товарный вид.
|
color: 'text.secondary',
|
||||||
|
lineHeight: 1.65,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Если товар не соответствует описанию или имеет производственный дефект, свяжитесь с нами в течение 7 дней после получения. Мы заменим изделие на аналогичное или вернём деньги. Возврат товара надлежащего качества возможен в течение 14 дней, если изделие не было в употреблении и сохранён его товарный вид.
|
||||||
</Typography>
|
</Typography>
|
||||||
</Paper>
|
</Box>
|
||||||
<Paper variant="outlined" sx={{ p: 2, borderRadius: 2 }}>
|
|
||||||
<Typography variant="subtitle1" sx={{ fontWeight: 600 }} gutterBottom>
|
<Box>
|
||||||
|
<Typography
|
||||||
|
sx={{
|
||||||
|
fontWeight: 600,
|
||||||
|
fontSize: '0.875rem',
|
||||||
|
letterSpacing: '-0.01em',
|
||||||
|
mb: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
Гарантия качества
|
Гарантия качества
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="body2" color="text.secondary">
|
<Typography
|
||||||
Мы отвечаем за качество каждого изделия ручной работы. Все дефекты, возникшие не по вине покупателя,
|
sx={{
|
||||||
устраняются или компенсируются заменой изделия. Если у вас возникли вопросы по качеству — напишите нам, и мы
|
fontSize: '0.8rem',
|
||||||
решим проблему в кратчайшие сроки.
|
color: 'text.secondary',
|
||||||
|
lineHeight: 1.65,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Мы отвечаем за качество каждого изделия ручной работы. Все дефекты, возникшие не по вине покупателя, устраняются или компенсируются заменой изделия. Если у вас возникли вопросы по качеству — напишите нам, и мы решим проблему в кратчайшие сроки.
|
||||||
</Typography>
|
</Typography>
|
||||||
</Paper>
|
</Box>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Paper>
|
</Box>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import { useMutation } from '@tanstack/react-query'
|
|||||||
import { useUnit } from 'effector-react'
|
import { useUnit } from 'effector-react'
|
||||||
import { useForm } from 'react-hook-form'
|
import { useForm } from 'react-hook-form'
|
||||||
import { useSearchParams } from 'react-router-dom'
|
import { useSearchParams } from 'react-router-dom'
|
||||||
|
import { getErrorMessage } from '@/shared/lib/get-error-message'
|
||||||
|
import { isSyntheticEmail } from '@/shared/lib/is-synthetic-email'
|
||||||
import {
|
import {
|
||||||
$user,
|
$user,
|
||||||
changePasswordFx,
|
changePasswordFx,
|
||||||
@@ -19,7 +21,6 @@ import {
|
|||||||
unlinkOAuthFx,
|
unlinkOAuthFx,
|
||||||
type AuthMethod,
|
type AuthMethod,
|
||||||
} from '@/shared/model/auth'
|
} from '@/shared/model/auth'
|
||||||
import { getErrorMessage } from '@/shared/lib/get-error-message'
|
|
||||||
|
|
||||||
const METHOD_LABELS: Record<string, string> = { password: 'Пароль', vk: 'ВКонтакте', yandex: 'Яндекс' }
|
const METHOD_LABELS: Record<string, string> = { password: 'Пароль', vk: 'ВКонтакте', yandex: 'Яндекс' }
|
||||||
|
|
||||||
@@ -117,6 +118,13 @@ export function AuthMethodsSection() {
|
|||||||
{user.email}
|
{user.email}
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
|
{isSyntheticEmail(user.email) && (
|
||||||
|
<Alert severity="info" sx={{ mb: 2 }}>
|
||||||
|
Ваша почта сгенерирована автоматически. Без указания реальной почты вы не сможете получать уведомления о
|
||||||
|
заказах.
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
{!verificationUrl && (
|
{!verificationUrl && (
|
||||||
<Stack direction="row" spacing={1} sx={{ mb: 2 }}>
|
<Stack direction="row" spacing={1} sx={{ mb: 2 }}>
|
||||||
<TextField
|
<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 Switch from '@mui/material/Switch'
|
||||||
import Typography from '@mui/material/Typography'
|
import Typography from '@mui/material/Typography'
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { useUnit } from 'effector-react'
|
||||||
import {
|
import {
|
||||||
fetchUserNotificationSettings,
|
fetchUserNotificationSettings,
|
||||||
updateUserNotificationSettings,
|
updateUserNotificationSettings,
|
||||||
} from '@/entities/notification/api/notifications-api'
|
} from '@/entities/notification/api/notifications-api'
|
||||||
import type { UserNotificationSettings } 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 {
|
function isOrderStatusChangesOn(s: UserNotificationSettings): boolean {
|
||||||
return s.orderCreated && s.orderStatusChanged && s.paymentStatusChanged && s.deliveryFeeAdjusted
|
return s.orderCreated && s.orderStatusChanged && s.paymentStatusChanged && s.deliveryFeeAdjusted
|
||||||
@@ -26,6 +29,7 @@ const orderStatusChangesPayload = (on: boolean) => ({
|
|||||||
export function NotificationsPage() {
|
export function NotificationsPage() {
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const user = useUnit($user)
|
||||||
|
|
||||||
const { data, isLoading } = useQuery({
|
const { data, isLoading } = useQuery({
|
||||||
queryKey: ['me', 'notifications', 'settings'],
|
queryKey: ['me', 'notifications', 'settings'],
|
||||||
@@ -63,6 +67,12 @@ export function NotificationsPage() {
|
|||||||
Настройте, какие уведомления вы хотите получать на почту.
|
Настройте, какие уведомления вы хотите получать на почту.
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
|
{user && isSyntheticEmail(user.email) && (
|
||||||
|
<Alert severity="info" sx={{ mb: 2 }}>
|
||||||
|
Ваша почта сгенерирована автоматически. Для получения уведомлений укажите реальную почту в настройках профиля.
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<Alert severity="error" sx={{ mb: 2 }}>
|
<Alert severity="error" sx={{ mb: 2 }}>
|
||||||
{error}
|
{error}
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ import { deliveryCarrierLabelRu } from '@/shared/constants/delivery-carrier'
|
|||||||
import { PICKUP_ADDRESS_FULL } from '@/shared/constants/pickup-point'
|
import { PICKUP_ADDRESS_FULL } from '@/shared/constants/pickup-point'
|
||||||
import { formatPriceRub } from '@/shared/lib/format-price'
|
import { formatPriceRub } from '@/shared/lib/format-price'
|
||||||
import { parseOrderAddressSnapshot } from '@/shared/lib/order-address-snapshot'
|
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() {
|
export function OrderDetailPage() {
|
||||||
const { id } = useParams()
|
const { id } = useParams()
|
||||||
@@ -130,7 +130,14 @@ export function OrderDetailPage() {
|
|||||||
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} sx={{ mb: 2, alignItems: { sm: 'center' } }}>
|
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} sx={{ mb: 2, alignItems: { sm: 'center' } }}>
|
||||||
<Box sx={{ flexGrow: 1 }}>
|
<Box sx={{ flexGrow: 1 }}>
|
||||||
<Typography variant="h4">Заказ #{order.id.slice(-6)}</Typography>
|
<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>
|
</Box>
|
||||||
<Button component={RouterLink} to="/me/orders" variant="outlined">
|
<Button component={RouterLink} to="/me/orders" variant="outlined">
|
||||||
К списку
|
К списку
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { useUnit } from 'effector-react'
|
|||||||
import { $user } from '@/shared/model/auth'
|
import { $user } from '@/shared/model/auth'
|
||||||
import { AuthMethodsSection } from './AuthMethodsSection'
|
import { AuthMethodsSection } from './AuthMethodsSection'
|
||||||
import { AvatarSection } from './AvatarSection'
|
import { AvatarSection } from './AvatarSection'
|
||||||
|
import { DeleteAccountSection } from './DeleteAccountSection'
|
||||||
import { ProfileSection } from './ProfileSection'
|
import { ProfileSection } from './ProfileSection'
|
||||||
|
|
||||||
export function SettingsPage() {
|
export function SettingsPage() {
|
||||||
@@ -35,6 +36,7 @@ export function SettingsPage() {
|
|||||||
<AuthMethodsSection />
|
<AuthMethodsSection />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
<DeleteAccountSection />
|
||||||
</Stack>
|
</Stack>
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,46 +1,47 @@
|
|||||||
import Box from '@mui/material/Box'
|
import Box from '@mui/material/Box'
|
||||||
import Paper from '@mui/material/Paper'
|
import Paper from '@mui/material/Paper'
|
||||||
import Typography from '@mui/material/Typography'
|
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 SITE_URL = STORE_PUBLIC_SITE_URL || (typeof window !== 'undefined' ? window.location.origin : '')
|
||||||
const OP_INN = '402900832341'
|
|
||||||
const OP_OGRN = '305402922700051'
|
const OP_FULL = `${STORE_OP_NAME} (${STORE_OP_TYPE})`
|
||||||
const OP_ADDR = '248000, Россия, г. Калуга, ул. Никитина, д. 12А'
|
|
||||||
const SITE_URL = window.location.origin
|
|
||||||
|
|
||||||
const sections = [
|
const sections = [
|
||||||
{
|
{
|
||||||
title: '1. Общие положения',
|
title: '1. Общие положения',
|
||||||
items: [
|
items: [
|
||||||
`1.1. Настоящая Политика конфиденциальности (далее — Политика) действует в отношении всех персональных данных, которые ${OP_NAME} (далее — Оператор) может получить от Пользователя во время использования сайта ${SITE_URL}.`,
|
`1.1. Настоящая Политика конфиденциальности (далее — Политика) действует в отношении всех персональных данных, которые ${OP_FULL} (далее — Оператор) может получить от Пользователя во время использования сайта ${SITE_URL}.`,
|
||||||
`1.2. ИНН Оператора: ${OP_INN}`,
|
`1.2. ИНН Оператора: ${STORE_OP_INN}`,
|
||||||
`1.3. ОГРН/ОГРНИП Оператора: ${OP_OGRN}`,
|
`1.3. Адрес Оператора: ${STORE_OP_ADDR}`,
|
||||||
`1.4. Адрес Оператора: ${OP_ADDR}`,
|
`1.4. Контактный email: ${STORE_EMAIL}`,
|
||||||
`1.5. Контактный email: ${STORE_EMAIL}`,
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '2. Персональные данные, которые обрабатывает Оператор',
|
title: '2. Персональные данные, которые обрабатывает Оператор',
|
||||||
items: [
|
items: [
|
||||||
'2.1. Оператор обрабатывает следующие персональные данные Пользователей:',
|
'2.1. Оператор обрабатывает следующие персональные данные Пользователей:',
|
||||||
'— фамилия, имя, отчество;',
|
|
||||||
'— адрес электронной почты;',
|
'— адрес электронной почты;',
|
||||||
'— номер телефона;',
|
'— имя (отображаемое имя, может быть указано Пользователем добровольно);',
|
||||||
'— данные файлов cookie;',
|
'— ФИО получателя, номер телефона и адрес доставки (указывается Пользователем добровольно при оформлении доставки);',
|
||||||
'— данные о действиях на сайте (аналитика);',
|
'— сессионные cookie-файлы (исключительно для поддержания входа в Личный кабинет).',
|
||||||
'— адрес доставки и геолокационные координаты.',
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '3. Цели обработки персональных данных',
|
title: '3. Цели обработки персональных данных',
|
||||||
items: [
|
items: [
|
||||||
'3.1. Оператор обрабатывает персональные данные в следующих целях:',
|
'3.1. Оператор обрабатывает персональные данные в следующих целях:',
|
||||||
'— идентификация Пользователя;',
|
'— идентификация и аутентификация Пользователя;',
|
||||||
'— оказание услуг / продажа товаров;',
|
'— оказание услуг / продажа товаров и оформление доставки;',
|
||||||
'— направление уведомлений и информационных сообщений;',
|
'— направление транзакционных уведомлений о статусе заказов и информационных сообщений;',
|
||||||
'— улучшение качества работы сайта;',
|
'— улучшение качества работы сайта.',
|
||||||
'— построение персонализированных предложений и рекомендаций.',
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -56,9 +57,9 @@ const sections = [
|
|||||||
{
|
{
|
||||||
title: '5. Порядок и условия обработки',
|
title: '5. Порядок и условия обработки',
|
||||||
items: [
|
items: [
|
||||||
'5.1. Обработка осуществляется путём сбора, записи, систематизации, накопления, хранения, уточнения, извлечения, использования, передачи, обезличивания, блокирования, удаления и уничтожения персональных данных.',
|
'5.1. Обработка осуществляется путём сбора, записи, систематизации, накопления, хранения, уточнения, извлечения, использования, передачи, блокирования, удаления и уничтожения персональных данных.',
|
||||||
'5.2. Обработка осуществляется автоматизированным и неавтоматизированным способами.',
|
'5.2. Обработка осуществляется автоматизированным способом с использованием программных средств Сайта.',
|
||||||
'5.3. Срок хранения персональных данных: не более 7 лет с момента последнего обращения Пользователя либо до момента отзыва согласия на обработку.',
|
'5.3. Срок хранения персональных данных: до достижения целей обработки либо до момента отзыва Пользователем согласия на обработку.',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -67,14 +68,23 @@ const sections = [
|
|||||||
'6.1. Оператор может передать персональные данные третьим лицам в следующих случаях:',
|
'6.1. Оператор может передать персональные данные третьим лицам в следующих случаях:',
|
||||||
'— с согласия субъекта;',
|
'— с согласия субъекта;',
|
||||||
'— по требованию законодательства РФ;',
|
'— по требованию законодательства РФ;',
|
||||||
'— для выполнения договорных обязательств (перечень третьих лиц): службы доставки, платёжные агрегаторы, сервисы аналитики (Яндекс.Метрика).',
|
'— для выполнения договорных обязательств (перечень третьих лиц): службы доставки, платёжный сервис (ЮKassa).',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '7. Права субъекта персональных данных',
|
title: '7. Использование cookie-файлов',
|
||||||
items: [
|
items: [
|
||||||
'7.1. Пользователь имеет право на доступ к своим данным, их уточнение, блокирование или уничтожение.',
|
'7.1. Оператор использует сессионные cookie-файлы исключительно для поддержания аутентификации Пользователя в Личном кабинете.',
|
||||||
'7.2. Для реализации своих прав Пользователь может направить запрос на электронный адрес: ' + STORE_EMAIL,
|
'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"
|
color="text.secondary"
|
||||||
sx={{ mb: 4, pb: 3, borderBottom: '1px solid', borderColor: 'divider' }}
|
sx={{ mb: 4, pb: 3, borderBottom: '1px solid', borderColor: 'divider' }}
|
||||||
>
|
>
|
||||||
Политика в отношении обработки персональных данных.
|
Последнее обновление: 23 мая 2026 г.
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
<Box component="section" sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
<Box component="section" sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||||
|
|||||||
@@ -1,14 +1,19 @@
|
|||||||
import Box from '@mui/material/Box'
|
import Box from '@mui/material/Box'
|
||||||
import Paper from '@mui/material/Paper'
|
import Paper from '@mui/material/Paper'
|
||||||
import Typography from '@mui/material/Typography'
|
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 SITE_URL = STORE_PUBLIC_SITE_URL || (typeof window !== 'undefined' ? window.location.origin : '')
|
||||||
|
|
||||||
const OP_NAME = 'Индивидуальный предприниматель Новоселова Наталия Владимировна'
|
const OP_FULL = `${STORE_OP_NAME} (${STORE_OP_TYPE})`
|
||||||
const OP_INN = '402900832341'
|
|
||||||
const OP_OGRN = '305402922700051'
|
|
||||||
const OP_ADDR = '248000, Россия, г. Калуга, ул. Никитина, д. 12А'
|
|
||||||
|
|
||||||
const sections = [
|
const sections = [
|
||||||
{
|
{
|
||||||
@@ -17,9 +22,9 @@ const sections = [
|
|||||||
`1.1. Настоящее Пользовательское соглашение (далее — «Соглашение») определяет порядок и условия использования материалов и сервисов, размещённых в сети Интернет по адресу ${SITE_URL} (далее — «Сайт»), Пользователями данного Сайта.`,
|
`1.1. Настоящее Пользовательское соглашение (далее — «Соглашение») определяет порядок и условия использования материалов и сервисов, размещённых в сети Интернет по адресу ${SITE_URL} (далее — «Сайт»), Пользователями данного Сайта.`,
|
||||||
`1.2. Использование Пользователями Сайта означает, что они безоговорочно принимают и обязуются соблюдать все условия настоящего Соглашения.`,
|
`1.2. Использование Пользователями Сайта означает, что они безоговорочно принимают и обязуются соблюдать все условия настоящего Соглашения.`,
|
||||||
'1.3. В настоящем Соглашении используются следующие термины:',
|
'1.3. В настоящем Соглашении используются следующие термины:',
|
||||||
`— Администратор — ${OP_NAME}, ИНН ${OP_INN}, ОГРН ${OP_OGRN}, адрес: ${OP_ADDR}, которому принадлежат все соответствующие права на Сайт.`,
|
`— Администратор — ${OP_FULL}, ИНН ${STORE_OP_INN}, адрес: ${STORE_OP_ADDR}, которому принадлежат все соответствующие права на Сайт.`,
|
||||||
`— Акцепт — полное и безоговорочное принятие условий настоящего Соглашения, размещённого на Сайте по адресу ${SITE_URL}/terms, осуществляемое путём совершения Пользователем любых действий по использованию Сайта.`,
|
`— Акцепт — полное и безоговорочное принятие условий настоящего Соглашения, размещённого на Сайте по адресу ${SITE_URL}/terms, осуществляемое путём совершения Пользователем любых действий по использованию Сайта.`,
|
||||||
'— Аутентификационные данные Пользователя — адрес электронной почты Пользователя и пароль (код доступа), которые в совокупности признаются простой электронной подписью Пользователя.',
|
'— Аутентификационные данные Пользователя — адрес электронной почты и пароль (код доступа), либо данные, полученные через сервисы авторизации третьих лиц (VK ID, Яндекс ID), либо одноразовый код, направляемый на электронную почту. Совокупность аутентификационных данных признаётся простой электронной подписью Пользователя.',
|
||||||
'— Пользователь — лицо, осуществляющее доступ к Сайту и использующее материалы и сервисы, размещённые на Сайте.',
|
'— Пользователь — лицо, осуществляющее доступ к Сайту и использующее материалы и сервисы, размещённые на Сайте.',
|
||||||
'— Контент — любое информационно значимое наполнение Сайта, включая фото, текст и иные медиаматериалы.',
|
'— Контент — любое информационно значимое наполнение Сайта, включая фото, текст и иные медиаматериалы.',
|
||||||
'— Личный кабинет — персонализированная часть Сайта, посредством которой обеспечивается обмен информацией между Пользователем и Сайтом.',
|
'— Личный кабинет — персонализированная часть Сайта, посредством которой обеспечивается обмен информацией между Пользователем и Сайтом.',
|
||||||
@@ -53,7 +58,7 @@ const sections = [
|
|||||||
'3.4. Пользователь самостоятельно несёт ответственность за сохранность своих Аутентификационных данных и за все действия, совершённые с их использованием.',
|
'3.4. Пользователь самостоятельно несёт ответственность за сохранность своих Аутентификационных данных и за все действия, совершённые с их использованием.',
|
||||||
'3.5. Пользователь обязан незамедлительно уведомить Администратора о любом случае несанкционированного доступа к Личному кабинету.',
|
'3.5. Пользователь обязан незамедлительно уведомить Администратора о любом случае несанкционированного доступа к Личному кабинету.',
|
||||||
'3.6. Пользователь не вправе воспроизводить, копировать, продавать и использовать в коммерческих целях Сайт или его Контент без разрешения Администратора.',
|
'3.6. Пользователь не вправе воспроизводить, копировать, продавать и использовать в коммерческих целях Сайт или его Контент без разрешения Администратора.',
|
||||||
'3.7. При регистрации Пользователь даёт согласие на получение информационных и рекламных сообщений от Администратора на указанный адрес электронной почты.',
|
'3.7. При регистрации Пользователь даёт согласие на получение транзакционных уведомлений (статус заказа, сообщения в чате заказа, статус оплаты) на указанный адрес электронной почты.',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -79,12 +84,13 @@ const sections = [
|
|||||||
'5.6. Пользователь не вправе нарушать нормальную работу отдельных сервисов и Сайта в целом.',
|
'5.6. Пользователь не вправе нарушать нормальную работу отдельных сервисов и Сайта в целом.',
|
||||||
'5.7. Пользователь обязан самостоятельно отслеживать внесение изменений в настоящее Соглашение.',
|
'5.7. Пользователь обязан самостоятельно отслеживать внесение изменений в настоящее Соглашение.',
|
||||||
'5.8. Пользователь вправе прекратить доступ к Личному кабинету, направив уведомление Администратору.',
|
'5.8. Пользователь вправе прекратить доступ к Личному кабинету, направив уведомление Администратору.',
|
||||||
|
'5.9. Пользователь вправе самостоятельно удалить свою учётную запись через Настройки Личного кабинета.',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '6. Ограничение ответственности',
|
title: '6. Ограничение ответственности',
|
||||||
items: [
|
items: [
|
||||||
'6.1. Администратор гарантирует достоверность и полноту только той информации, которую он разместил на Сайте самостоятельно.',
|
'6.1. Администратор прилагает разумные усилия для обеспечения достоверности и полноты информации, размещённой на Сайте, однако не даёт явных гарантий точности такой информации.',
|
||||||
'6.2. Администратор не несёт ответственности за недостоверность информации, размещённой третьими лицами, в том числе Пользователями.',
|
'6.2. Администратор не несёт ответственности за недостоверность информации, размещённой третьими лицами, в том числе Пользователями.',
|
||||||
'6.3. Администратор не гарантирует, что Сайт будет соответствовать требованиям Пользователя, работать непрерывно и без ошибок.',
|
'6.3. Администратор не гарантирует, что Сайт будет соответствовать требованиям Пользователя, работать непрерывно и без ошибок.',
|
||||||
'6.4. Администратор не несёт ответственности перед Пользователем за любые убытки, включая упущенную выгоду, потерю данных, вред деловой репутации, причинённые в связи с использованием Сайта.',
|
'6.4. Администратор не несёт ответственности перед Пользователем за любые убытки, включая упущенную выгоду, потерю данных, вред деловой репутации, причинённые в связи с использованием Сайта.',
|
||||||
@@ -95,21 +101,17 @@ const sections = [
|
|||||||
{
|
{
|
||||||
title: '7. Доступ к ресурсам третьих лиц',
|
title: '7. Доступ к ресурсам третьих лиц',
|
||||||
items: [
|
items: [
|
||||||
'7.1. Доступ Пользователя к Сайту может вызывать обращение к интернет-ресурсам третьих лиц (реклама, сбор статистики).',
|
'7.1. Для обеспечения функциональности Сайта используются сервисы третьих лиц: платёжный сервис ЮKassa (для обработки онлайн-платежей)',
|
||||||
'7.2. Владельцы таких ресурсов имеют техническую возможность собирать информацию о Пользователях и самостоятельно определяют условия её использования.',
|
'7.2. Владельцы указанных ресурсов имеют собственную политику конфиденциальности и самостоятельно определяют условия обработки получаемой информации.',
|
||||||
'7.3. При переходе на сторонние ресурсы Пользователи самостоятельно определяют пределы использования своей информации согласно правилам соответствующих ресурсов.',
|
'7.3. При переходе на сторонние ресурсы Пользователи самостоятельно определяют пределы использования своей информации согласно правилам соответствующих ресурсов.',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '8. Информация, хранящаяся на стороне браузера',
|
title: '8. Информация, хранящаяся на стороне браузера',
|
||||||
items: [
|
items: [
|
||||||
'8.1. Администратор использует cookie-файлы для определения уникального идентификатора доступа Пользователя к Сайту.',
|
'8.1. Администратор использует сессионные cookie-файлы исключительно для поддержания аутентификации Пользователя в Личном кабинете.',
|
||||||
'8.2. Цели использования cookie:',
|
'8.2. Сайт не использует cookie для сбора статистики, отслеживания действий Пользователя или показа рекламы.',
|
||||||
'— поддержка функциональности Сайта, требующей использования cookie;',
|
'8.3. Пользователь может запретить использование cookie в настройках браузера, однако это приведёт к невозможности входа в Личный кабинет и использования функций, требующих аутентификации.',
|
||||||
'— измерение аудитории Сайта;',
|
|
||||||
'— определение статистических предпочтений Пользователей;',
|
|
||||||
'— исследование корреляции статистических данных.',
|
|
||||||
'8.3. Пользователь может запретить использование cookie в настройках браузера, однако это может привести к частичной или полной потере функциональности Сайта.',
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -117,16 +119,16 @@ const sections = [
|
|||||||
items: [
|
items: [
|
||||||
`9.1. Обработка персональных данных Пользователей осуществляется Администратором в соответствии с Политикой конфиденциальности, размещённой по адресу ${SITE_URL}/privacy.`,
|
`9.1. Обработка персональных данных Пользователей осуществляется Администратором в соответствии с Политикой конфиденциальности, размещённой по адресу ${SITE_URL}/privacy.`,
|
||||||
'9.2. Передавая свои персональные данные при регистрации или заполнении форм на Сайте, Пользователь даёт согласие на их обработку Администратором.',
|
'9.2. Передавая свои персональные данные при регистрации или заполнении форм на Сайте, Пользователь даёт согласие на их обработку Администратором.',
|
||||||
'9.3. Администратор обрабатывает следующие персональные данные: Ф. И. О., адрес электронной почты, номер телефона, IP-адрес, тип браузера, данные о действиях на Сайте.',
|
'9.3. Администратор обрабатывает следующие персональные данные: адрес электронной почты, имя (при добровольном указании), номер телефона (при оформлении доставки), адрес доставки.',
|
||||||
'9.4. Цели обработки персональных данных: обеспечение функционирования Сайта, оказание информационной поддержки, предоставление персонализированных сервисов, направление информационных сообщений.',
|
'9.4. Цели обработки персональных данных: обеспечение функционирования Сайта, аутентификация Пользователя, оформление и доставка заказов, направление транзакционных уведомлений.',
|
||||||
`9.5. Согласие на обработку персональных данных может быть отозвано Пользователем путём направления заявления на адрес электронной почты: ${STORE_EMAIL}.`,
|
`9.5. Пользователь вправе отозвать согласие на обработку персональных данных путём удаления учётной записи через Настройки Личного кабинета либо путём направления заявления на адрес электронной почты: ${STORE_EMAIL}.`,
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '10. Изменение условий и расторжение соглашения',
|
title: '10. Изменение условий и расторжение соглашения',
|
||||||
items: [
|
items: [
|
||||||
'10.1. Соглашение может быть расторгнуто в любое время по инициативе любой из сторон. Администратор уведомляет о расторжении путём размещения информации на Сайте.',
|
'10.1. Соглашение может быть расторгнуто в любое время по инициативе любой из сторон. Администратор уведомляет о расторжении путём размещения информации на Сайте.',
|
||||||
`10.2. Пользователь может расторгнуть Соглашение, направив уведомление на адрес электронной почты: ${STORE_EMAIL}.`,
|
`10.2. Пользователь может расторгнуть Соглашение, удалив учётную запись через Личный кабинет, либо направив уведомление на адрес электронной почты: ${STORE_EMAIL}.`,
|
||||||
'10.3. Администратор вправе в одностороннем порядке изменять условия Соглашения. Новая редакция вступает в силу с момента размещения на Сайте.',
|
'10.3. Администратор вправе в одностороннем порядке изменять условия Соглашения. Новая редакция вступает в силу с момента размещения на Сайте.',
|
||||||
'10.4. Продолжение использования Сайта после изменения условий означает согласие Пользователя с новой редакцией. При несогласии Пользователь обязуется прекратить использование Сайта.',
|
'10.4. Продолжение использования Сайта после изменения условий означает согласие Пользователя с новой редакцией. При несогласии Пользователь обязуется прекратить использование Сайта.',
|
||||||
],
|
],
|
||||||
@@ -134,10 +136,10 @@ const sections = [
|
|||||||
{
|
{
|
||||||
title: '11. Информация об Администраторе',
|
title: '11. Информация об Администраторе',
|
||||||
items: [
|
items: [
|
||||||
`${OP_NAME}`,
|
STORE_OP_NAME,
|
||||||
`ИНН: ${OP_INN}`,
|
`Статус: ${STORE_OP_TYPE}`,
|
||||||
`ОГРН: ${OP_OGRN}`,
|
`ИНН: ${STORE_OP_INN}`,
|
||||||
`Адрес: ${OP_ADDR}`,
|
`Адрес: ${STORE_OP_ADDR}`,
|
||||||
`Телефон: ${STORE_PHONE}`,
|
`Телефон: ${STORE_PHONE}`,
|
||||||
`Email: ${STORE_EMAIL}`,
|
`Email: ${STORE_EMAIL}`,
|
||||||
],
|
],
|
||||||
@@ -161,7 +163,7 @@ export function TermsPage() {
|
|||||||
color="text.secondary"
|
color="text.secondary"
|
||||||
sx={{ mb: 4, pb: 3, borderBottom: '1px solid', borderColor: 'divider' }}
|
sx={{ mb: 4, pb: 3, borderBottom: '1px solid', borderColor: 'divider' }}
|
||||||
>
|
>
|
||||||
Последнее обновление: 19 мая 2026 г.
|
Последнее обновление: 23 мая 2026 г.
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
<Box component="section" sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
<Box component="section" sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import axios, { AxiosHeaders } from 'axios'
|
import axios, { AxiosHeaders } from 'axios'
|
||||||
import { apiBaseURL } from '@/shared/config'
|
import { apiBaseURL } from '@/shared/config'
|
||||||
|
|
||||||
// Глобальный application/json ломает FormData: axios сериализует его в JSON. Тип для JSON задаёт transformRequest.
|
|
||||||
export const apiClient = axios.create({
|
export const apiClient = axios.create({
|
||||||
baseURL: apiBaseURL,
|
baseURL: apiBaseURL,
|
||||||
})
|
})
|
||||||
@@ -13,7 +12,6 @@ apiClient.interceptors.request.use((config) => {
|
|||||||
if (token) {
|
if (token) {
|
||||||
config.headers.set('Authorization', `Bearer ${token}`)
|
config.headers.set('Authorization', `Bearer ${token}`)
|
||||||
}
|
}
|
||||||
// FormData: нельзя задавать Content-Type вручную (нужен boundary). Иначе сервер не видит файлы → { urls: [] }.
|
|
||||||
if (config.data instanceof FormData) {
|
if (config.data instanceof FormData) {
|
||||||
config.headers.delete('Content-Type')
|
config.headers.delete('Content-Type')
|
||||||
config.headers.delete('content-type')
|
config.headers.delete('content-type')
|
||||||
@@ -23,3 +21,13 @@ apiClient.interceptors.request.use((config) => {
|
|||||||
return 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 = 'Любимый Креатив'
|
export const STORE_NAME = 'Любимый Креатив'
|
||||||
|
|
||||||
/** Канонический URL сайта для политики конфиденциальности и т.п.; в dev без env — `window.location.origin`. */
|
/** Канонический URL сайта для юридических текстов (политика конфиденциальности и т.п.). */
|
||||||
export const STORE_PUBLIC_SITE_URL = (() => {
|
export const STORE_PUBLIC_SITE_URL = (() => {
|
||||||
const raw =
|
const raw =
|
||||||
typeof import.meta.env.VITE_PUBLIC_SITE_URL === 'string' ? import.meta.env.VITE_PUBLIC_SITE_URL.trim() : ''
|
typeof import.meta.env.VITE_PUBLIC_SITE_URL === 'string' ? import.meta.env.VITE_PUBLIC_SITE_URL.trim() : ''
|
||||||
if (raw) return raw.replace(/\/$/, '')
|
if (raw) return raw.replace(/\/$/, '')
|
||||||
if (typeof window !== 'undefined') return window.location.origin
|
if (typeof window !== 'undefined') return window.location.origin
|
||||||
return ''
|
return 'https://любимыйкреатив.рф'
|
||||||
})()
|
})()
|
||||||
|
|
||||||
/** Демо-контакты для футера; при необходимости задайте через VITE_* в `.env`. */
|
/** Демо-контакты для футера; при необходимости задайте через VITE_* в `.env`. */
|
||||||
export const STORE_EMAIL = import.meta.env.VITE_STORE_EMAIL ?? 'larisa8502@yandex.ru'
|
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 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 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 =
|
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: 'Черновик',
|
DRAFT: 'Черновик',
|
||||||
PENDING_PAYMENT: 'Ожидает оплаты',
|
PENDING_PAYMENT: 'Ожидает оплаты',
|
||||||
PAID: 'Оплачен',
|
PAID: 'Оплачен',
|
||||||
IN_PROGRESS: 'В работе',
|
IN_PROGRESS: 'Подготовка к отправке',
|
||||||
SHIPPED: 'Отправлен',
|
SHIPPED: 'Отправлен',
|
||||||
READY_FOR_PICKUP: 'Готово к получению',
|
READY_FOR_PICKUP: 'Готово к получению',
|
||||||
DONE: 'Завершён',
|
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 фронта (через запятую при нескольких)
|
# Разрешённый Origin фронта (через запятую при нескольких)
|
||||||
# CORS_ORIGIN=http://127.0.0.1:5173
|
# CORS_ORIGIN=http://127.0.0.1:5173
|
||||||
|
|
||||||
|
# Ограничение доступа по IP на время разработки (через запятую). Не задано — защита отключена.
|
||||||
|
# SITE_ACCESS_IPS=1.2.3.4,5.6.7.8
|
||||||
|
|
||||||
# Публичные URL для OAuth redirect (локально обычно так):
|
# Публичные URL для OAuth redirect (локально обычно так):
|
||||||
SERVER_PUBLIC_URL=http://127.0.0.1:3333
|
SERVER_PUBLIC_URL=http://127.0.0.1:3333
|
||||||
CLIENT_PUBLIC_URL=http://127.0.0.1:5173
|
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 { prisma } from './lib/prisma.js'
|
||||||
import { getMaxUploadBodyBytes, getProductImageMaxFileBytes } from './lib/upload-limits.js'
|
import { getMaxUploadBodyBytes, getProductImageMaxFileBytes } from './lib/upload-limits.js'
|
||||||
import { registerAuth } from './plugins/auth.js'
|
import { registerAuth } from './plugins/auth.js'
|
||||||
|
import { registerIpGate } from './plugins/ip-gate.js'
|
||||||
import { registerApiRoutes } from './routes/api.js'
|
import { registerApiRoutes } from './routes/api.js'
|
||||||
import { registerOAuthSocialRoutes } from './routes/oauth-social.js'
|
import { registerOAuthSocialRoutes } from './routes/oauth-social.js'
|
||||||
import { registerUploadsResized } from './routes/uploads-resized.js'
|
import { registerUploadsResized } from './routes/uploads-resized.js'
|
||||||
@@ -89,6 +90,7 @@ const notificationQueue = createNotificationQueue()
|
|||||||
fastify.decorate('eventBus', eventBus)
|
fastify.decorate('eventBus', eventBus)
|
||||||
fastify.decorate('notificationQueue', notificationQueue)
|
fastify.decorate('notificationQueue', notificationQueue)
|
||||||
|
|
||||||
|
await registerIpGate(fastify)
|
||||||
registerAuth(fastify)
|
registerAuth(fastify)
|
||||||
await registerUserAddressRoutes(fastify)
|
await registerUserAddressRoutes(fastify)
|
||||||
await registerUserCartRoutes(fastify)
|
await registerUserCartRoutes(fastify)
|
||||||
|
|||||||
@@ -10,10 +10,10 @@ export function renderOrderStatusChangedTg({ orderId, oldStatus, newStatus }) {
|
|||||||
DRAFT: 'Черновик',
|
DRAFT: 'Черновик',
|
||||||
PENDING_PAYMENT: 'Ожидает оплаты',
|
PENDING_PAYMENT: 'Ожидает оплаты',
|
||||||
PAID: 'Оплачен',
|
PAID: 'Оплачен',
|
||||||
IN_PROGRESS: 'В работе',
|
IN_PROGRESS: 'Подготовка к отправке',
|
||||||
READY_FOR_PICKUP: 'Готов к выдаче',
|
READY_FOR_PICKUP: 'Готов к выдаче',
|
||||||
SHIPPED: 'Отправлен',
|
SHIPPED: 'Отправлен',
|
||||||
DONE: 'Выполнен',
|
DONE: 'Завершён',
|
||||||
CANCELLED: 'Отменён',
|
CANCELLED: 'Отменён',
|
||||||
}
|
}
|
||||||
return `🔄 Заказ #${orderId.slice(0, 8)}\n${labels[oldStatus] || oldStatus} → <b>${labels[newStatus] || newStatus}</b>`
|
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) }
|
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([
|
export const ORDER_STATUSES = Object.freeze([
|
||||||
'DRAFT',
|
"DRAFT",
|
||||||
'PENDING_PAYMENT',
|
"PENDING_PAYMENT",
|
||||||
'PAID',
|
"PAID",
|
||||||
'IN_PROGRESS',
|
"IN_PROGRESS",
|
||||||
'SHIPPED',
|
"SHIPPED",
|
||||||
'READY_FOR_PICKUP',
|
"READY_FOR_PICKUP",
|
||||||
'DONE',
|
"DONE",
|
||||||
'CANCELLED',
|
"CANCELLED",
|
||||||
])
|
]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Допустимые переходы статусов, доступные админу.
|
* Допустимые переходы статусов, доступные админу.
|
||||||
@@ -15,24 +15,24 @@ export const ORDER_STATUSES = Object.freeze([
|
|||||||
* Для IN_PROGRESS: объект с ключами по deliveryType.
|
* Для IN_PROGRESS: объект с ключами по deliveryType.
|
||||||
*/
|
*/
|
||||||
export const ADMIN_ORDER_TRANSITIONS = Object.freeze({
|
export const ADMIN_ORDER_TRANSITIONS = Object.freeze({
|
||||||
DRAFT: ['PENDING_PAYMENT', 'CANCELLED'],
|
DRAFT: ["PENDING_PAYMENT", "CANCELLED"],
|
||||||
PENDING_PAYMENT: ['PAID', 'CANCELLED'],
|
PENDING_PAYMENT: ["PAID", "CANCELLED"],
|
||||||
PAID: ['IN_PROGRESS', 'CANCELLED'],
|
PAID: ["IN_PROGRESS", "CANCELLED"],
|
||||||
IN_PROGRESS: Object.freeze({
|
IN_PROGRESS: Object.freeze({
|
||||||
delivery: ['SHIPPED', 'CANCELLED'],
|
delivery: ["SHIPPED", "CANCELLED"],
|
||||||
pickup: ['READY_FOR_PICKUP', 'CANCELLED'],
|
pickup: ["READY_FOR_PICKUP", "CANCELLED"],
|
||||||
}),
|
}),
|
||||||
})
|
});
|
||||||
|
|
||||||
export function getNextAdminStatuses(from, deliveryType) {
|
export function getNextAdminStatuses(from, deliveryType) {
|
||||||
const transition = ADMIN_ORDER_TRANSITIONS[from]
|
const transition = ADMIN_ORDER_TRANSITIONS[from];
|
||||||
if (!transition) return []
|
if (!transition) return [];
|
||||||
if (Array.isArray(transition)) return [...transition]
|
if (Array.isArray(transition)) return [...transition];
|
||||||
return transition[deliveryType] ? [...transition[deliveryType]] : []
|
return transition[deliveryType] ? [...transition[deliveryType]] : [];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function canTransitionAdminOrderStatus(order, next) {
|
export function canTransitionAdminOrderStatus(order, next) {
|
||||||
const from = order.status
|
const from = order.status;
|
||||||
if (from === next) return true
|
if (from === next) return true;
|
||||||
return getNextAdminStatuses(from, order.deliveryType).includes(next)
|
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