# Mobile design system

## Характер интерфейса

Интерфейс спокойный и функциональный: светлая база, тёмный текст, крупные карточки, один очевидный primary action. Бренд клуба заметен на обложке, карте и ключевых акцентах, но не перекрашивает системные статусы и не ухудшает читаемость.

## Базовые токены концепта

| Токен | Значение в макете | Назначение |
|---|---|---|
| `--ink` | `#102044` | Основной текст и сильный контраст |
| `--primary` | `#246BFD` | Действия платформы до выбора клуба |
| `--club` | `#7256E8` | Primary brand выбранного клуба |
| `--club-accent` | `#FF7A59` | Вторичный акцент клуба |
| `--surface` | `#FFFFFF` | Карточки и sheets |
| `--canvas` | `#F2F5FA` | Фон экранов |
| `--success` | `#14966F` | Подтверждённые действия и активные состояния |
| `--warning` | `#B66A05` | Внимание и приближение лимита |
| `--danger` | `#D73C55` | Ошибка или destructive action |

Эти значения одновременно являются нейтральными migration defaults club-side mobile configuration. В существующей вкладке SuperAdmin-настроек их можно заменить брендингом клуба; отдельного первичного setup-экрана нет.

Цветовые токены в React Native должны быть semantic (`textPrimary`, `actionPrimary`, `statusDanger`), а не называться по оттенку. Это позволит менять branding без переписывания компонентов.

## Branding configuration

Первая версия может поддерживать:

- `logoLightUrl`, `logoDarkUrl` и компактный mark — только versioned BFF media URLs;
- `primaryColor` и опциональный `accentColor`;
- `surfaceTint` для мягкого фона обложек;
- `onPrimaryColor`, вычисленный или явно заданный после contrast validation;
- `clubCoverUrl` для home hero через BFF media proxy или безопасный градиент по умолчанию;
- `clientCardArtworkUrl` для фона цифровой карты, не затрагивающий белую область barcode/QR;
- `borderRadiusScale`: только из ограниченного набора, а не произвольный CSS;
- общие контакты сети, юридическое название, timezone и локаль; address/phone/coordinates/working hours выбранного филиала приходят отдельно в branch DTO и не являются branding overrides;
- capability flags: guest booking, group waitlist, trainer booking, cancellation;
- provider metadata для client-card value, но не готовые локализованные подписи.

Card-value provider является частью только Mobile Integration contract: он определяет отображаемое значение карты и поле mobile legacy linking. Его metadata не является общей стратегией поиска CRM и не влияет на существующий поиск клиента, check-in или terminal/public lookup в админке.

Все `*Url` в mobile branding contract указывают только на semantic BFF route `clubId + slot + brandVersion`. Storage account, container, blob URL и SAS никогда не передаются приложению: BFF находит source URL в валидированной конфигурации клуба, стримит изображение и отдаёт cache headers. Сам Blob доступен на чтение по прямому URL для preview в CRM admin UI, но mobile этот URL не использует.

В строках каталога приоритет имеет квадратный `logo-compact`: он занимает фиксированный thumbnail и не влияет на высоту карточки. Если URL отсутствует, загрузка завершилась ошибкой или файл нельзя декодировать, компонент показывает инициалы из названия клуба на branded/neutral background. Название всегда остаётся рядом, поэтому логотип не является единственным способом идентификации клуба.

Изображение абонемента не является branding slot: оно принадлежит конкретному виду `DbAbonement` и приходит в membership DTO отдельным semantic BFF URL. Оно используется в preview главной, списке и деталях; при отсутствии компонент полностью переходит на компактную icon-layout. Фото тренера берётся из существующей карточки сотрудника и использует инициалы как fallback. Произвольные баннеры, branch gallery и картинки каждого занятия в v1 отсутствуют.

Branding применяется после выбора клуба и кэшируется BFF. Если конфигурация устарела или недоступна, приложение использует последнюю валидную версию либо нейтральную тему InfCRM. Критические цвета `success`, `warning`, `danger`, disabled и focus не переопределяются клубом.

Альтернативный branding можно локально отрендерить через `render-mockups.mjs`: скрипт показывает ту же главную с другим набором semantic tokens. Компоненты, размеры и контраст при этом не меняются.

## Типографика и плотность

- Системный шрифт платформы: San Francisco на iOS, Roboto на Android.
- Основной текст: 15–16 px, `line-height` не менее 1.4.
- Заголовок экрана: 26–30 px, semibold/bold.
- Вторичные подписи: не меньше 12 px; смысл не кодируется только уменьшением текста.
- Числа остатков и даты используют tabular numerals, если они сравниваются вертикально.
- Длинные названия клуба, тренера и абонемента допускают две строки до truncation.

## Пространство и формы

- Базовый spacing grid: 4 px; основные интервалы 8, 12, 16, 20, 24, 32.
- Боковой padding экрана: 20 px.
- Tap target: не менее 44×44 pt; primary button — 50–54 pt по высоте.
- Радиусы: 12 для полей, 16 для малых карточек, 22–28 для ключевых поверхностей.
- Bottom sheet используется для короткого выбора в текущем контексте; полноценный экран — для решения с последствиями.

## Компоненты

| Компонент | Правило |
|---|---|
| `ClubContextHeader` | Всегда показывает выбранный клуб/филиал и открывает switcher |
| `ClientCard` | Не смешивает card value, client number и внутренний remote ID |
| `MembershipCard` | Состояние, срок и остаток видны без открытия деталей |
| `SessionCard` | Время, название, тренер, места и статус записи в одном scan path |
| `BranchContactCard` | Показывает только доступные branch contact/location/hours данные; status работы вычисляет из structured intervals и timezone, системные dialer/maps actions не требуют device permissions |
| `PrimaryButton` | На экране не более одного визуально доминирующего действия |
| `StatusBanner` | Причина + влияние + следующий шаг, без технического текста |
| `Skeleton` | Совпадает с размером будущего контента |
| `EmptyState` | Объясняет причину и содержит одно полезное действие |

## Карта и штрихкод

- Максимальный контраст: чёрные штрихи на белой quiet zone; brand background не заходит под код.
- Под кодом показывается human-readable значение, если provider разрешает его отображение.
- Яркость экрана можно временно повышать при открытой карте, с восстановлением при уходе.
- При динамическом provider UI показывает срок действия и обновляет значение до истечения.
- Screenshot policy и offline policy определяются типом provider; нельзя предполагать, что любой код статический.
- При нескольких разных значениях пользователь выбирает карту по названию абонемента/назначению, а не по техническому provider id.

## Accessibility

- Контраст обычного текста не ниже 4.5:1, крупного — 3:1.
- Все статусы дублируются текстом и иконкой, не только цветом.
- Dynamic Type до 200% не скрывает primary action и не перекрывает нижнюю навигацию.
- Screen reader получает осмысленные названия: «Открыть карту Pulse Fitness», а не «Кнопка barcode».
- Таймеры и даты озвучиваются абсолютным временем; не только «через 2 часа».
- Reduced Motion отключает декоративные переходы и пульсацию skeleton.
- Ошибка поля связывается с самим полем и озвучивается после submit.

## Локализация

- V1 содержит ровно три language catalogs: русский (`ru`), английский (`en`) и украинский (`uk`; не `ua`).
- Региональные варианты `ru-*`, `en-*` и `uk-*` сводятся к language code; для остальных device locales используется полный английский fallback.
- Пользователь может сменить язык в профиле независимо от языка или branding выбранного клуба.
- Английский catalog является source/fallback, а сборка не проходит completeness check при отсутствии ключа хотя бы в одном из трёх обязательных catalogs.
- Компоненты проектируются с запасом на различную длину перевода: текст не встраивается в изображения, кнопки не получают фиксированную ширину по одной локали.

API возвращает stable key и данные, приложение выбирает текст. Например:

```json
{
  "linkingField": "clientNumber",
  "mask": "########",
  "helpKey": "linking.clientNumber.help"
}
```

Так русская подпись «Номер клиента» не становится частью API-контракта. Значения дат форматируются по locale пользователя, но правила клуба показываются в timezone филиала с явным названием филиала.

## Возрастное ограничение v1

- Условие 18+ находится в обязательных versioned Terms и принимается как часть обычного legal flow.
- Отдельного age-gate, пугающего blocking screen или самостоятельного age-confirmation action в приложении нет.
- Дата рождения запрашивается только на форме создания гостевого `DbClient`, передаётся через BFF без сохранения и хранится в CRM. Детских профилей, parental consent и guardian/dependent сценариев в v1 нет.
- В v1 linking не читает CRM-дату рождения, а guest create проверяет только корректность календарной даты; age-specific ошибки и строгая проверка возраста отложены вместе с child/guardian/dependent flows.

## Вход и номер телефона

- Экран входа показывает только доступного провайдера текущей платформы: Google на Android, Apple на iOS. Недоступные и будущие способы входа не анонсируются в UI.
- Страна по умолчанию берётся из `regionCode` устройства через [`expo-localization`](https://docs.expo.dev/versions/latest/sdk/localization/). Если регион получить нельзя, поле остаётся без флага до распознавания введённого пользователем `+кода`; отдельный picker не открывается.
- Флаг, международный код и формат номера берутся из актуальных country/phone metadata, а не из собственного списка приложения. Для разбора, форматирования и E.164-нормализации можно использовать metadata [`libphonenumber-js`](https://github.com/catamphetamine/libphonenumber-js).
- Флаг не является отдельным способом выбора страны: он автоматически меняется после распознавания `+кода`. В профиль и BFF отправляется нормализованный номер E.164; распознанный ISO alpha-2 можно хранить отдельно как UI-предпочтение.
- Отсутствие SMS-проверки не комментируется на форме. Если подтверждение появится позднее, для него добавляется отдельный шаг с кодом, а не техническая подпись под полем.

## Что проверить перед разработкой UI

- Макеты на ширинах 320, 375, 390 и 430 pt.
- iOS safe areas, Android gesture/navigation insets и клавиатуру.
- Очень длинные названия сети, филиала, занятия и тренера.
- 0, 1, 2 и 10+ абонементов; одинаковые и разные card values.
- Светлый логотип на светлом фоне, отсутствующий логотип и невалидный brand color.
- Loading, offline, retry, partial cache и повторный submit.
- Dynamic Type, VoiceOver/TalkBack, клавиатурную навигацию web-preview.
