Перейти к основному содержимому

ТЗ: Пользовательское фото в NFT-карточке статуса (nft-card-photo)

Метаданные

ПараметрЗначение
Дата создания2026-08-05
Дата последнего изменения2026-08-05
Статус апрува✅ Одобрено
Дата апрува2026-08-05

1. Назначение

Сейчас карточка статусного NFT — это готовая картинка из облака (Level{NN}_NFT_04.webp), одинаковая у всех пользователей одного уровня. Текст «MAGNET / Status NFT / 12⁰⁴» впечатан в саму картинку.

Задача — дать пользователю загрузить своё фото, которое подставляется внутрь рамки карточки. Карточка перестаёт быть монолитной картинкой и собирается на фронте из трёх слоёв: фото пользователя → PNG-рамка с прозрачным окном → текстовая подпись.

Затрагивает apps/backend (новое поле + эндпоинты) и apps/frontend (новый пикер, новый компонент карточки, две точки вывода).


2. Функциональные требования

2.1. Пользовательские сценарии

  1. Загрузка. Пользователь заходит в настройки профиля, видит блок «Фото для NFT-карточки» с превью своей карточки. Нажимает «Загрузить фото», выбирает изображение, кадрирует его в соотношении 10/16 и подтверждает. Фото сохраняется, превью и карточка на всех страницах обновляются сразу.
  2. Просмотр. Пользователь открывает /career — в хедере вместо стандартной картинки уровня видит свою карточку: фото в фирменной рамке, снизу «MAGNET / Status NFT» и его текущий уровень.
  3. Профиль. То же самое в хедере профиля (/profile/[id]) — и на своём, и на чужом. Если у чужого пользователя загружено фото, посетитель видит его карточку с фото.
  4. Удаление. Пользователь нажимает «Удалить» — фото стирается, везде возвращается стандартная картинка уровня.
  5. Без фото. Пользователь, который ничего не загружал, видит ровно то же, что и сейчас, — Level{NN}_NFT_04.webp из облака.

2.2. Бизнес-логика

Отдельная сущность

Фото для карточки — самостоятельное поле nftCardPhoto, не связанное с существующими avatar, avatarLg, nftAvatar, nftAvatarLg и с переключателем «Обычный аватар / NFT аватар». Загрузка фото для карточки не меняет обычный аватар и наоборот.

Что выводится на карточке

ЭлементИсточник
Фотоuser.nftCardPhoto
Рамкастатичный ассет public/images/nft/Level_NFT_AVA_0.webp
«MAGNET»статичный текст, не локализуется
«Status NFT»статичный текст, не локализуется
Крупная цифрауровень пользователя, дополненный нулём до двух знаков (404, 1212)
Надстрочная цифраколичество продуктов: 04; при уровне 000

Источник уровня — тот же, что уже используется в каждой точке вывода:

  • /career — проп userLevel в PreprodCareerHeader (значение из diamond-контракта);
  • профиль — Math.max(user.lp1, user.lp2) в UserProfileHeader.

Правило «00 продуктов на нулевом уровне» повторяет текущее поведение getPathToNftImage, где нулевой уровень маппится в Level00_NFT_00.webp.

Фолбэк

Если nftCardPhoto пуст — карточка рендерится как одна картинка getPathToNftImage(level), ровно как сейчас. Слоёная вёрстка и текстовая подпись в этом случае не применяются: текст уже впечатан в webp, и его дублирование дало бы двойную надпись.

Видимость

Поле публичное: приходит в ответе GET /user/uid/:uid и видно всем пользователям на странице чужого профиля.

2.3. UI/UX требования

Рамка-шаблон

Исходник — temp/Level_NFT_AVA_0.png, 2000×3200 (соотношение ровно 10/16). Карта альфа-канала по вертикали:

Диапазон (% высоты)АльфаЧто там
0 – 8,3 %непрозрачноверхняя кромка, логотип, 4 иконки
8,3 – 60,9 %полностью прозрачноокно под фото
60,9 – 76,2 %плавное затухание к непрозрачномупереход фото в плашку
76,2 – 100 %непрозрачнонижняя плашка под текст

Фото растягивается на весь бокс с object-fit: cover — при кропе 10/16 оно точно попадает в окно.

Верстка карточки

<Box>                       aspect-ratio: 10/16; container-type: inline-size
<img src={photo} /> position:absolute; inset:0; object-fit:cover
<img src={FRAME} /> position:absolute; inset:0; pointer-events:none
<Box className="caption"> position:absolute; низ карточки (зона 76–100 %)
MAGNET крупный, жирный, белый
Status NFT мелкий, синий
12⁰⁴ очень крупная цифра уровня + надстрочные продукты
</Box>
</Box>

Размеры шрифтов задаются в cqw (container query units), потому что карточка выводится в трёх разных размерах: 225 px на /career, 156 px в профиле, ~150 px в превью настроек. Раскладка подписи и пропорции текста — по референсу из скриншота профиля: «MAGNET» и «Status NFT» блоком слева, цифра уровня справа, надстрочные продукты — верхним индексом у цифры.

Пикер в настройках

Отдельный блок в CardSettingsEditPage, вторым по счёту, под существующим блоком аватара:

[Аватар 10/16]  Загрузить новое фото | Сбросить
( ) Обычный ( ) NFT аватар
──────────────────────────────────────────────
[Карточка ] Фото для NFT-карточки
[с рамкой ] Загрузить фото | Удалить

Отличие от существующего пикера: один шаг кадрирования (10/16) вместо двух (10/16 + круглый 50 px), и результат уходит в отдельное поле.


3. Техническая реализация

3.1. Архитектура

Настройки: NftCardPhotoUploader
└─ <label> + <input type="file"> → ImageCropDialog (aspect 10/16, maxSize 800, quality 80)
└─ POST /settings/nft-card-photo → IpfsService (Yandex S3)
└─ users.nft_card_photo = <url>
└─ updateUser({ nftCardPhoto }) в effector-сторе

Вывод: NftStatusCard(level, photo)
├─ photo пуст → <img src={getPathToNftImage(level)} /> (как сейчас)
└─ photo есть → фото + рамка Level_NFT_AVA_0.webp + подпись
├─ PreprodCareerHeader (/career)
├─ UserProfileHeader (/profile/[id], свой и чужой)
└─ превью в настройках

3.2. Описание технической реализации

Backend (apps/backend)

ФайлИзменение
src/user/models/user.repository.ts@Column({ nullable: true, name: 'nft_card_photo' }) nftCardPhoto: string;
src/migrations/…-add-nft-card-photo.tsALTER TABLE users ADD COLUMN nft_card_photo VARCHAR(512) NULL + down с DROP COLUMN
src/app.module.tsмиграцию обязательно зарегистрировать в массиве BACKEND_MIGRATIONS — без этого migrationsRun её не применит, а Entity User уже ждёт колонку nft_card_photo в каждом SELECT по users
src/user/dto/user.dto.ts@ApiProperty() nftCardPhoto: string;
src/settings/settings.controller.tsPOST /settings/nft-card-photo (FileInterceptor('file'), JwtAuthGuard) и DELETE /settings/nft-card-photo
src/settings/settings.service.tsuploadNftCardPhoto(user, file)ipfsService.uploadFile(file)update{ nftCardPhoto }; deleteNftCardPhoto(userId)nftCardPhoto = null
src/settings/dto/NftCardPhotoResponseDto { nftCardPhoto: string }

GET /user/uid/:uid использует findOne без select, поэтому новое поле попадёт в ответ автоматически — правок не требует.

Загрузка идёт через тот же IpfsService, что и аватары: Yandex Object Storage, бакет magnet-images, ACL public-read, в БД пишется полный URL.

Frontend (apps/frontend)

ФайлИзменение
public/images/nft/Level_NFT_AVA_0.webpновый ассет: исходный PNG (1,2 МБ, 2000×3200) пережат в WebP с альфой ~1000×1600
src/shared/types/apps/user.types.tsnftCardPhoto?: string; в UserType
src/api/settings/settings.api.urls.tsnftCardPhoto: '/settings/nft-card-photo'
src/api/settings/settings.api.service.tsuploadNftCardPhoto(formData), deleteNftCardPhoto()
src/shared/ui/ImageCropDialog.tsxновый необязательный проп quality (по умолчанию 60 — текущее поведение)
src/entities/nft/ui/NftStatusCard.tsxновый компонент карточки (см. 2.3); импортируется прямым путём, не через barrel @entities/nft — он тянет модалки покупки NFT с контрактами
src/entities/settings/ui/NftCardPhotoUploader.tsxновый пикер: <label> + <input type="file"> + один ImageCropDialog, загрузка, удаление, updateUser
src/entities/settings/ui/CardSettingsEditPage.tsxновый Grid item с пикером под блоком аватара
src/entities/career/ui/PreprodCareerHeader.tsx<img src={getPathToNftImage(userLevel)} /><NftStatusCard …/>
src/entities/profile/ui/UserProfileHeader.tsxто же, photo берётся из пропса user (работает и для чужого профиля)

Константа количества продуктов (COUNT_OF_PRODUCTS = 4) сейчас объявлена внутри getPathToNftImage. Её нужно вынести в экспорт того же модуля, чтобы NftStatusCard не дублировал число.

Что не трогаем

Дерево (CardTree, CardCircleTree), смартлинк, круглый аватар в шапке, переключатель «Обычный аватар / NFT аватар» и существующий двухшаговый ImageUploader остаются как есть.

Тесты

Юнит-тест NftStatusCard.test.tsx рядом с существующими *.test.tsx:

  • без фото рендерится <img> с getPathToNftImage(level) и не рендерится подпись;
  • с фото рендерится фото + рамка + подпись;
  • уровень форматируется в два знака (404);
  • на уровне 0 надстрочные продукты — 00, на прочих — 04.

4. Проблемы и компромиссы

4.1. Известные ограничения

  1. Дрейф схемы на preprod

    • На preprod synchronize: false, и ранее наблюдалось расхождение: миграции числятся применёнными, а DDL отсутствует.
    • Влияние: после деплоя колонку nft_card_photo, возможно, придётся добавить вручную (ALTER TABLE users), иначе запросы к users упадут с Unknown column.
  2. Текст в фолбэк-картинке

    • В Level{NN}_NFT_04.webp подпись впечатана в изображение, а в новой рамке — нет.
    • Влияние: два режима рендера в одном компоненте; при фолбэке текстовый слой отключается.
  3. container-type: inline-size

    • Container queries поддерживаются всеми актуальными браузерами, но не старыми (Safari < 16).
    • Влияние: на устаревших браузерах подпись потеряет пропорциональность; критичного слома вёрстки нет.

4.2. Технический долг

  • Старое фото не удаляется из Yandex Object Storage при перезагрузке или удалении — в бакете копятся осиротевшие файлы. Так же ведёт себя и текущая загрузка аватаров; чистку стоит вынести в отдельную задачу.

4.3. Риски

РискМитигация
Колонка не появится на preprodПроверить SHOW COLUMNS FROM users LIKE 'nft_card_photo' после деплоя, при необходимости выполнить ALTER TABLE вручную
Пережатый WebP потеряет чёткость свечения рамкиСравнить визуально с исходным PNG, при заметной деградации поднять разрешение до 1500×2400
Подпись не совпадёт с референсом по позицииСверять с готовыми Level{NN}_NFT_04.webp — раскладка должна выглядеть одинаково

5. Вопросы на дополнительное обсуждение

Нет — все вопросы сняты на этапе проработки: шаблон Level_NFT_AVA_0.png, отдельное поле в БД, слоёная вёрстка вместо склейки в canvas, видимость для всех пользователей, фолбэк на текущую NFT-картинку.