This commit is contained in:
2026-06-19 12:43:29 +03:00
parent 990f7c8544
commit 663f6dc268
54 changed files with 2884 additions and 2207 deletions

137
src/pages/profile/README.md Normal file
View File

@@ -0,0 +1,137 @@
# Страница профиля (`/profile`)
Личный кабинет пользователя: показывает данные аккаунта, баланс, адреса кошельков
и даёт доступ к мнемонике. Маршрут `ROUTES.PROFILE` (`/profile`), защищён
`ProtectedRoute` (только для авторизованных).
## Главная идея
Страница — **тонкая композиция виджетов**. В `ProfilePage.tsx` нет ни хуков данных,
ни стейта, ни дериваций — только разметка. Вся логика и презентация живут в слайсе
`widgets/profile` и в нижних слоях (`features/auth`, `features/wallet`, `shared`).
Это сознательный результат рефакторинга под FSD (`app → pages → widgets → features →
entities → shared`; нижние слои не импортируют верхние).
Ключевой приём: **каждый под-виджет сам тянет свои данные** через хуки. Прокидывать
`data` пропсами со страницы не нужно — `useMe` использует `queryKey: ['me']` со
`staleTime/gcTime: Infinity`, поэтому вызов `useMe()` в нескольких компонентах
дедуплицируется в один сетевой запрос. Та же логика, что в `WalletPage`.
## Карта файлов
```
pages/profile/
ui/ProfilePage.tsx ← композиция (без логики)
ui/ProfilePage.module.css ← только внешний каркас: .page .main .sections
index.ts ← export { ProfilePage }
README.md ← этот файл
widgets/profile/ ← ВСЁ содержимое страницы живёт здесь
model/usePhoneField.ts ← логика редактирования телефона + стейт нотификации
ui/
ProfileSummary.tsx ← аватар + имя + баланс (self-fetch: useMe, usePortfolio)
ProfileDetails.tsx ← выбирает ветку individual/legal (self-fetch: useMe)
IndividualFields.tsx ← поля физлица; зовёт usePhoneField + рендерит Notification
LegalEntityFields.tsx ← поля юрлица (read-only, из data.legal_entity)
SecuritySection.tsx ← адреса кошельков (self-fetch: useWalletAddresses)
MnemonicSection.tsx ← кнопка → ROUTES.SEED_PHRASE
KycBanner.tsx ← баннер, если !kyc_verified (self-fetch: useMe)
ProfileAvatar.tsx ← загрузка/кроп аватара (был раньше)
AvatarCropModal.tsx ← модалка кропа (был раньше)
ProfileSection.tsx ← обёртка-карточка: title + children + actions (был раньше)
fieldsGrid.module.css ← общие .grid2/.grid1 (для Individual/Legal/Security)
*.module.css ← по одному на компонент с собственными стилями
index.ts ← барель: экспортит верхнеуровневые компоненты
features/auth/lib/profile.ts ← getFullName / getDisplayName / isLegalEntity (над MeResponse)
shared/lib/utils/formatUsd.ts← formatUsd(value) → "$1,234.56" | "$—"
```
`IndividualFields` и `LegalEntityFields`**внутренние** для виджета: в барель
(`widgets/profile/index.ts`) не выносятся, их использует только `ProfileDetails`
относительным импортом.
## Поток данных
| Источник | Откуда | Что даёт |
|---|---|---|
| `useMe()` | `@features/auth` | `MeResponse`: ФИО, email, паспорт, ИНН, `account_type`, `kyc_verified`, `legal_entity` |
| `usePortfolio()` | `@features/wallet` | `portfolio.totalUsd` для баланса |
| `useWalletAddresses()` | `@features/wallet` | массив `{ chain, address }` |
| `useUpdatePhone()` | `@features/auth` | мутация сохранения телефона (внутри `usePhoneField`) |
`MeResponse` определён в `features/auth/api/profileApi.ts`. Намеренно **не** перенесён
в пустой `entities/user` — это была бы отдельная миграция (затрагивает все импорты
типа). Если когда-нибудь будете наполнять `entities/user`, тип и хелперы из
`features/auth/lib/profile.ts` — первые кандидаты на переезд вниз.
## Развилка individual / legal
`ProfileDetails` решает по `isLegalEntity(data)` (`account_type !== 'individual'`):
- **individual** → `IndividualFields`: ФИО/email/паспорт/телефон + ИНН/ID. Телефон
**редактируемый**.
- **legal** → `LegalEntityFields`: данные организации/адреса/контакты из
`data.legal_entity` (если `legal_entity` пуст → рендерит `null`). Всё **read-only**
данные юрлица управляются на стороне админа.
## Логика телефона (`usePhoneField`)
Единственная нетривиальная логика на странице, живёт в `widgets/profile/model/usePhoneField.ts`
(по образцу `widgets/login-form/model/useLoginForm.ts`). Хук владеет:
- локальным `phone`, синхронизируемым с серверным значением через `useEffect`;
- санитизацией ввода (`onPhoneChange` — regex оставляет только `\d + пробел () -`);
- сохранением **по blur** (`onPhoneBlur`) с guard'ами: не сохраняет, если значение не
менялось или мутация уже в полёте;
- стейтом нотификации (успех/ошибка) — она побочный эффект мутации, вне хука смысла нет.
Рендер `<Notification>` — в `IndividualFields` (хук владеет стейтом, компонент —
рендером). Это тост из `@shared/ui`, позиционируется фиксированно.
## CSS
- `ProfilePage.module.css` — только внешний каркас страницы (flex-колонка, центрирование
`.main`, отступы, брейкпоинты 1023/639px для паддингов).
- Стили секций — рядом со своими компонентами в `widgets/profile/ui/*.module.css`.
- `fieldsGrid.module.css` — единственный осознанно общий стиль (`.grid2` 2 колонки →
1 колонка на <640px; `.grid1`), импортится из Individual/Legal/Security. Лежит
**внутри виджета**, поэтому ни один компонент не лезет в стиль страницы.
- Брейкпоинты по проекту: 1023 (десктоп→планшет), 649/639 (телефон), 549 (узкий телефон).
## Правила при доработке
1. **Страница остаётся тонкой.** Новый блок профиля = новый компонент в
`widgets/profile/ui/` + экспорт в барель + вставка в `ProfilePage.tsx`. Не тащите
хуки/стейт обратно в страницу.
2. **Данные — через self-fetch хуки**, не через пропсы со страницы (дедуп по queryKey).
3. **Деривации над `MeResponse`**`features/auth/lib/profile.ts` (там же тип). Не
дублируйте форматирование имени/`isLegal` в компонентах.
4. **Форматирование `$`-сумм**`formatUsd` из `@shared/lib/utils/formatUsd`.
5. Соблюдайте порядок слоёв: виджеты импортируют из features/shared, не наоборот.
## Известные точки расширения / TODO
- `ProfileSummary`: закомментирован рублёвый баланс (`.userBalanceRub`, «≈ … ₽») —
ждёт реального источника курса.
- `SecuritySection`: кнопки «⚠️ Посмотреть приватный ключ» и «СОХРАНИТЬ» сейчас
**без обработчиков** (заглушки из исходной вёрстки). При реализации навесить логику.
- `formatUsd` дублируется инлайном ещё в 5 местах (BalanceCard, WalletHeader,
TokenTable, useChainTokenRows, PoolsTable) — отдельная задача на дедупликацию.
Внимание: часть из них использует вариант с 46 знаками, а не фиксированные 2 —
при миграции, возможно, понадобится расширить `formatUsd` опциями точности.
## Проверка
```bash
npm run build # tsc -b + vite build (тестов в проекте нет)
npm run lint
npm run dev # ручная проверка /profile
```
Ручной чек-лист `/profile`:
- individual: поля видны; ввод телефона санитизируется; по blur сохраняется и
появляется тост «Номер телефона обновлён» (и тост ошибки при сбое);
- legal: секции организации/адресов/контактов; телефон read-only;
- баланс через `formatUsd``$—` при загрузке);
- KYC-баннер виден только при `!kyc_verified`;
- «Мнемоника» ведёт на `ROUTES.SEED_PHRASE`;
- адаптив на брейкпоинтах 1023/649/639/549 не сломан.