Как я собрал на одном Angular коде: личный кабинет для сайта, Telegram Mini App, iOS и Android

от автора

Я Angular-разработчик и один пишу фронтенд своего сервиса «НеБалкон». Сначала был только сайт, но довольно быстро понадоблись личный кабинет, Telegram Mini App и приложения для iOS и Android.

Требования формировал я сам и сам же их постонно менял. Волатильность была приличная: стартап всё-таки. Ищешь PMF, крутишь идею, меняешь УТП, находишь решение получше — и опять идёшь править формы, статусы и пользовательский флоу.

На весь фронтенд было примерно 3 месяца. В такой ситуации заводить отдельный Flutter-проект или, тем более, писать два native-клиента на Kotlin и Swift мне показалось плохой идеей. Я бы сначала изучал новый для меня стек, потом переносил в него уже написанную логику, а после каждой своей новой ‘гениальной’ идеи синхронно правил несколько реализаций.

Остаться на Angular было проще лично мне, но плюс не только в этом. Когда код общий, формы, модели, валидация и багфиксы не начинают жить своей жизнью на каждой платформе. Поменял сценарий один раз — он поменялся в вебе, TMA и мобильных приложениях.

Так сайт и кабинет остались на Angular, TMA получил тот же CSR-клиент, а iOS и Android — Angular внутри Capacitor. Сейчас все 16 экранов кабинета используют одну реализацию, а доля общего TypeScript-кода получилась около 95%.

Конечно, «общий код» не значит «вообще никаких отличий». Кнопка «Пополнить» везде одна, а способов вернуться с платёжной страницы у меня получилось три. Но до этого ещё дойдём.

Что вообще делает «НеБалкон». Кратко для контекста

Сайт сервиса

Сайт сервиса

Если коротко, «НеБалкон» — сервис хранения с забором и возвратом вещей. Клиент оформляет заявку, мы забираем вещи, храним их и возвращаем, когда они снова понадобятся.

В кабинете клиент видит свои вещи, управляет заявками, адресами и кошельком. То есть это не страница с парой кнопок, а основная точка взаимодействия с сервисом. Поэтому хотелось дать к нему доступ и из браузера, и из Telegram, и из обычного приложения.

На момент написания статьи публично работает сайт, а версии для Telegram, iOS и Android существуют как рабочие закрытые сборки.

Всё появлялось по очереди

Я не сел в первый день писать сразу четыре клиента. Сначала сделал сайт, затем кабинет в браузере, после него TMA, а уже потом iOS и Android.

С каждым новым клиентом я добавлял оболочку, но не переписывал профиль, список вещей, заявки и кошелёк. Иначе на очередной смене УТП пришлось бы открывать четыре проекта и вспоминать, где какой экран уже успел разъехаться.

Монорепозиторий: два Angular-приложения, общий кабинет и Go API

Всё это живёт в Nx-монорепозитории. Внутри два Angular-приложения с разными задачами.

apps/web — публичный сайт и кабинет в браузере. Публичные страницы рендерятся на сервере и затем гидратируются на клиенте. /dashboard/**, включая кабинет, работает в CSR. SSR я добавлял ради SEO, а не потому, что очень хотелось усложнить себе сборку.

apps/mobile — обычное CSR-приложение. Его сборка уезжает в Capacitor-проекты для iOS и Android, а тот же Angular-проект разворачивается как Telegram Mini App.

Сам кабинет лежит не внутри приложений, а в libs/cabinet-client и libs/cabinet-core. В apps/* остались верхнеуровневые маршруты, layout и выбор нужных адаптеров.

Backend у меня отдельный: Go, Gin, PostgreSQL и REST API. Все клиенты ходят в один API. Какой именно клиент прислал запрос, я передаю отдельно и использую только там, где это реально нужно. Например, чтобы вернуть пользователя после оплаты.

SSR сразу добавил одно правило: общий код не может считать, что window и document всегда на месте. Всё браузерное я оставляю в приложении или прячу за адаптером.

Где заканчивается «общий код»

«Одна кодовая база» — фраза скользкая. Иногда это один репозиторий, внутри которого всё равно лежат три версии одной кнопки. У меня общий именно код экранов и сценариев, а не только папка в Git.

В libs/cabinet-client я вынес:

  • shell клиентского кабинета;

  • главная страница и задачи;

  • профиль и адреса;

  • список вещей на хранении;

  • заявки на забор и возврат;

  • кошелёк и история операций;

  • тарифы и помощь;

  • маршруты, facades, формы и прикладная логика этих экранов.

Одна фабрика маршрутов лениво подключает все эти страницы. Web и mobile монтируют её под разными базовыми путями, условно /web-cabinet и /app-cabinet. Shell и навигация могут отличаться, сама страница — нет.

Это не значит, что приложение должно выглядеть везде пиксель в пиксель. Shell может менять заголовок, нижнюю навигацию, кнопку «Назад» и safe area. Тащить desktop sidebar на iPhone только ради переиспользования кода было бы уже странно.

Коротко про i18n

Язык сейчас один — русский. Но i18n всё равно пригодился как способ не свалить все тексты в один огромный JSON. Тексты сайта лежат в apps/web, кабинета — в libs/cabinet-client, общих компонентов — в libs/ui-kit. При запуске приложение подмешивает только нужные наборы, поэтому mobile не тащит за собой тексты лендинга.

Что именно делает Capacitor

Для iOS и Android у меня Capacitor. Не Cordova — я сам первое время называл его по старой памяти неправильно. Capacitor берёт Angular-сборку, кладёт её в native shell и даёт JavaScript-мост к возможностям устройства.

Внутри не ссылка на удалённый сайт: HTML, JavaScript и стили входят в саму мобильную сборку. Поэтому изменения фронтенда всё равно проходят обычный релиз в store. В native-проектах остаются permissions, URL schemes, associated domains и конфигурация плагинов.

Из плагинов мне понадобились Camera для фотографий, Barcode Scanner для QR, Browser и App для внешних ссылок и deep links, плюс Dialog, Keyboard и Haptics.

При этом импортировать плагины прямо в общий кабинет нельзя. Иначе libs/cabinet-client быстро зарастёт проверками if iosif telegramif browser, и весь мой «единый стек» закончится большим условным оператором.

Как я не пустил платформенный код в общий кабинет

Главная задача тут была не дать общим компонентам узнать о Capacitor, Telegram SDK и браузерных API. Компоненту всё равно, откуда пришла фотография. Ему нужен File[], а не лекция про Camera plugin.

Поэтому в cabinet-core я завёл DI-токены:

  • CABINET_DIALOG;

  • CABINET_PHOTO_SOURCE;

  • CABINET_QR_SCAN;

  • CABINET_EXTERNAL_URL;

  • CABINET_PATHS;

  • CABINET_PLATFORM.

Общий код просит «выбрать фотографии» или «открыть URL». А будет это <input type="file">, Capacitor Camera, window.location или Telegram WebApp API — решает адаптер.

Вот упрощённый пример с фотографиями:

export interface PhotoSourceAdapter {  pickPhotos(options?: { maxCount?: number }): Promise<File[]>;}export const CABINET_PHOTO_SOURCE =  new InjectionToken<PhotoSourceAdapter>("CABINET_PHOTO_SOURCE");// Общий код кабинетаconst photoSource = inject(CABINET_PHOTO_SOURCE);const files = await photoSource.pickPhotos({ maxCount: 1 });// apps/web{ provide: CABINET_PHOTO_SOURCE, useValue: webPhotoSourceAdapter }// apps/mobile{  provide: CABINET_PHOTO_SOURCE,  useFactory: () => createMobilePhotoSourceAdapter(inject(CameraService)),}

В web я подставляю CDK-диалоги, HTML file input, браузерный QR scanner и обычное открытие URL. В mobile — Capacitor Dialog, Camera, Barcode Scanner и Browser. TMA запускается с теми же mobile providers, а нужная Telegram-ветка выбирается уже внутри адаптера.

Так проверки платформы лежат в нескольких понятных местах, а не размазаны по компонентам. Новая среда всё равно потребует интеграции и тестирования, но сами фичи переписывать не нужно.

WEB

WEB
TMA

TMA

Telegram Mini App без отдельного frontend-проекта

Отдельное Angular-приложение для TMA я делать не стал. Telegram запускает тот же apps/mobile внутри своего WebView.

На старте я проверяю window.Telegram.WebApp, платформу и наличие initData, затем вызываю ready() и expand(). В запросе клиент передаёт необязательную подсказку о своей среде: web, native или mini-app.

Важный момент: эта подсказка не участвует в авторизации и не доказывает, что запрос реально пришёл из Telegram. Авторизация работает отдельно. Признак клиента нужен только для некритичного платформенного поведения, например для выбора способа возврата после оплаты.

Одна кнопка «Пополнить», три сценария возврата

С пополнением кошелька пришлось повозиться. Компонент запрашивает платёжную ссылку и открывает её — тут всё одинаково. А вот возвращать пользователя после оплаты приходится по-разному.

В web это обычный URL кабинета. В native-приложении — custom scheme и deep link, условно example-app://wallet/return. В TMA — HTTPS URL внутри домена Mini App: custom scheme там не поможет.

В native отдельная обёртка открывает браузер оплаты и временно сохраняет идентификатор операции. После закрытия браузерного окна приложение возвращает пользователя на условный /app/wallet?ref=<paymentRef>. В TMA платёжная форма открывается внутри WebView, а результат приходит по HTTPS redirect.

В итоге бизнес-операция одна. Разъехался только транспорт вокруг платёжной страницы — и это как раз нормальная работа для адаптера.

Capacitor не отменяет мобильную вёрстку

Наивно было бы завернуть сайт в WebView и считать задачу закрытой. Мобильный viewport всё равно живёт своей жизнью.

В полноэкранных layout я использую dvh, а не vh: видимая высота меняется вместе с browser chrome и клавиатурой. Отступы считаю через env(safe-area-inset-*), иначе кнопка легко уезжает под вырез или системную панель. В native события клавиатуры приходят через Capacitor Keyboard, в TMA и браузере есть fallback на visualViewport.

С модалками та же история. Текстовое подтверждение показываю через нативный Dialog, список действий — через Action Sheet, сложную форму — через Angular CDK Dialog. Засунуть всё в системный alert хочется ровно до момента, когда туда понадобились три поля, календарь и фотография.

А код точно общий?

Написать «95% кода общие» легко, поэтому я всё-таки посчитал SLOC. Срез сделан по состоянию на июль 2026 года.

Считал рабочий TypeScript без пустых строк и комментариев. Выкинул тесты, сгенерированые файлы, нативный и сторонний код, а также результаты сборки. Общий код учитывал один раз, оболочки и адаптеры — отдельно.

Для клиентского кабинета получилось:

  • libs/cabinet-client: 17 973 общих SLOC;

  • web wrapper: 12 SLOC;

  • mobile wrapper для iOS, Android и TMA: 29 SLOC;

  • web adapters и interceptor: 299 SLOC;

  • mobile/TMA adapters: 648 SLOC.

Формула получилась такая:

17 973 / (17 973 + 12 + 29 + 299 + 648) = 94,8%

Важно: 94,8% относятся только к TypeScript-коду кабинета. Сюда не входят Xcode, Gradle, публикация в stores, общий deep-link routing и прочая обвязка приложения. Если считать только фичи и точки монтирования, вышло бы 99,8%, но эта цифра уже слишком красивая и мало что говорит о реальной стоимости.

Есть метрика попроще: все 16 экранов кабинета лежат в общей библиотеке. Отдельных копий страниц для web, iOS, Android и Telegram нет.

LOC, конечно, не измеряет сложность и не доказывает качество. Но на вопрос «сколько TypeScript мне пришлось бы поддерживать отдельно» отвечает вполне нормально.

Где пришлось заплатить

Главный плюс я уже описал: поменял поле в заявке или статус заказа один раз — изменение приехало во все клиенты. Для меня это ещё и меньше переключений контекста: один TypeScript, один router, один подход к DI и один набор инструментов.

Но бесплатным это решение не было.

WebView не становится native от хорошей архитектуры. Нативное приложение может быстрее запускаться, лучше выглядеть и точнее соблюдать привычки платформы. Если бы у меня были тяжёлая графика, сложные анимации или фоновые процессы, я бы уже смотрел в другую сторону.

Платформы всё равно нужно знать. Оплата, deep links, permissions, клавиатура, safe area и публикация в store никуда не делись. Общий Angular-код не освобождает от Xcode, Gradle и чтения документации Apple в самый неподходящий момент.

Границу общего кода приходится защищать. Стоит пару раз написать Capacitor.isNativePlatform() прямо в компоненте, и очень быстро таких проверок становится двадцать. Поэтому новые отличия я стараюсь сразу уносить в адаптеры.

UI всё равно компромиссный. Можно аккуратно адаптировать интерфейс, но полностью нативным он не станет. Для «НеБалкона» это нормально: у меня в основном формы, списки, карточки и API. Ради них содержать отдельные native-клиенты пока нет смысла.

Что я бы сделал так же

Если бы начинал ещё раз, оставил бы четыре решения:

  1. Сначала общий кабинет, потом оболочки. Страницы и бизнес-сценарии должны жить в библиотеке, а не в первом приложении, которое случайно появилось раньше остальных.

  2. Абстрагировать конкретную проблему. Не делать огромный PlatformService, а заводить отдельные контракты для камеры, диалога, QR и внешних URL.

  3. Не врать себе красивым процентом. Адаптеры, deep links, тесты на устройствах и релизы в stores тоже стоят времени, даже если не попали в LOC общей фичи.

  4. Подключать каналы по очереди. Сначала довести сценарий в web, потом нести его в TMA и native-оболочки. Так хотя бы понятно, что именно сломалось.

Для моего MVP такой подход сработал. Пока платформеные отличия помещаются в адаптерах, я остаюсь на одном стеке и не усложняю. Если проверки платформы начнут расползаться по компонентам и диктовать устройство самих фич — значит, пора пересматривать архитектуру или стек.

Если есть вопросы, нужна помощь с подобным решением у вас — напишите мне в LinkedIn или Telegram.

ссылка на оригинал статьи https://habr.com/ru/articles/1064680/