Мы делаем QoyHunter — учётную систему для овцеводческих хозяйств Узбекистана, с витриной животных, доставкой и блогом на соседних поддоменах. С точки зрения бизнес-логики ничего экзотического: CRM как CRM. Экзотика начинается там, где эта CRM встречается с реальностью рынка.
А реальность такая: основной канал пользователей — Telegram, а не браузер; основной язык — не всегда русский (узбекский обязателен, английский — по явному выбору); часовой пояс один (Ташкент, UTC+5, без перехода на летнее время — тоже давало баги, но об этом ниже); а разработка идёт на Windows-ноутбуках без сервера БД под рукой. Из этих фактов вытекает добрая половина решений ниже. Стек: бэкенд на ASP.NET Core (.NET 10), фронт на React + Vite, админка на react-admin, всё за nginx в docker compose.
1. Скрыть продукт целиком, а не просто спрятать кнопку в интерфейсе
Сам продукт ещё не запущен публично: на проде сейчас живут только публичные поверхности — лендинг «скоро открытие» с формой сбора лидов, витрина, доставка и блог. При этом CRM целиком (учёт, аналитика, финансы) уже написана и задеплоена на тот же самый бэкенд, потому что раскатывать отдельную сборку «без продукта» и потом накатывать вторую «с продуктом» — двойная работа и двойной риск разъехаться.
Первая идея была наивной: спрятать соответствующие пункты меню на фронте. Она не выдерживает пяти минут критики — весь API уже стоит на проде, и достаточно открыть DevTools или просто угадать URL, чтобы увидеть, что «скоро» на самом деле уже готово. Прятать нужно бэкенд, а не фронт.
Решили через default-deny middleware: пока включён флаг StubMode, публично доступен только явный allowlist префиксов /api/*, всё остальное возвращает 404 — как будто маршрута не существует вовсе. Ключевое свойство: allowlist явный и короткий, а не блеклист. Если завтра кто-то добавит новый контроллер AnimalHealthController, он окажется скрытым автоматически, просто по факту существования флага, — не нужно помнить «а, точно, надо ещё и этот эндпоинт спрятать».
// backend/src/QoyHunter.Api/Middleware/StubModeGateMiddleware.cs private static readonly string[] AllowedApiPrefixes = [ "/api/public", // публичная конфигурация фронта (StubMode, botUsername, языки) "/api/legal", // публичные юр-документы "/api/client-logs", // приём клиентских ошибок лендинга-заглушки "/api/feedback", // публичная форма обратной связи Этапа 0 "/api/delivery", // второй публичный сервис: заявки на доставку животных "/api/market", // третий публичный сервис: маркет — витрина, породы, заказы "/api/blog", // блог: публичное чтение, комментарии/реакции // …плюс пара служебных префиксов, вырезанных из листинга ]; public async Task InvokeAsync(HttpContext context) { if (!features.CurrentValue.StubMode) { await next(context); return; } // гейт неактивен // /api-путь сверяем с allowlist через StartsWithSegments, // совпадение → next()… // Скрытый эндпоинт основного продукта: 404 без тела — как будто маршрута не существует. context.Response.StatusCode = StatusCodes.Status404NotFound; }
Отдельно пришлось разобраться со Swagger: он рефлексией собирает описание вообще всех контроллеров и по умолчанию отдаётся вне /api, то есть штатно проходит мимо этого гейта. В Program.cs включение Swagger дополнительно завязано на !StubMode — иначе спецификация API молча слила бы список всех «скрытых» ручек любому, кто наберёт /swagger.
Позже появился второй, более мягкий флаг PreLaunch: API остаётся открытым, просто бот и Mini App разворачивают пользователя на лендинг, а продукт живёт по прямой ссылке для своих. Два разных флага под две разные задачи. Решение не самое элегантное, из-за чего нужен и комментарий в FeatureOptions.cs , который объясняет разницу, чтобы не перепутать через полгода. :)
«Скрыть фичу до релиза» оказалось вопросом авторизации на бэкенде, а не видимости на фронте. Причём вопросом архитектурным: default-deny с явным allowlist не даёт забыть спрятать что-то новое, blacklist рано или поздно даёт дыру.

2. Настоящий Postgres на деве, который гарантированно умирает вместе с процессом
Локальная разработка ведётся на Windows-ноутбуках без Docker Desktop под рукой (или просто без желания держать его постоянно запущенным ради одной локальной БД). Хотелось настоящий Postgres — тот же движок, что в проде, — но без системного сервиса, который висит в фоне и о котором через месяц забываешь, потребляя память и держа порт занятым.
Взяли MysticMind.PostgresEmbed — библиотеку, которая распаковывает бинарники Postgres и управляет процессом postmaster. Но у обёртки есть подвох: по умолчанию она создаёт новый instanceId (GUID) на каждый запуск — а значит, новый каталог кластера на ~135 МБ, инициализированный заново, без данных с прошлого раза. Через неделю разработки на диске лежит десяток мёртвых каталогов, а данные не переживают перезапуск API. Фикс — зафиксировать instanceId константой:
// backend/src/QoyHunter.LocalDb/EmbeddedPostgres.cs /// <summary> /// Стабильный идентификатор инстанса — гарантирует единственный каталог кластера между запусками. /// Значение фиксировано намеренно (не случайное), иначе каталоги-инстансы копятся и данные не персистятся. /// </summary> private static readonly Guid StableInstanceId = new("7f3c1a90-0000-4000-8000-00c0ffee0001");
Вторая часть задачи интереснее: гарантировать, что после смерти нашего API процесс postgres.exe не останется висеть в фоне. IDisposable.Dispose() тут не спасает: DI-контейнер не диспозит инстансы, зарегистрированные через AddSingleton(instance) (он их не создавал — незачем и убирать), поэтому останов вызывается явно на IHostApplicationLifetime.ApplicationStopping. Но это штатный путь — а если API просто kill -9, упадёт, или его прибьют кнопкой Stop в IDE? Managed-код отработать не успеет.
Для этого случая — Windows Job Object с флагом JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE: ОС сама убивает назначенный процесс в момент закрытия хендла джоба, а хендл закрывается вместе с нашим процессом — что бы с ним ни случилось.
// backend/src/QoyHunter.LocalDb/WindowsProcessJob.cs private const uint JobObjectLimitKillOnJobClose = 0x2000; var info = new JOBOBJECT_EXTENDED_LIMIT_INFORMATION { BasicLimitInformation = new JOBOBJECT_BASIC_LIMIT_INFORMATION { LimitFlags = JobObjectLimitKillOnJobClose, }, }; // ... var process = OpenProcess(ProcessTerminate | ProcessSetQuota, false, pid); AssignProcessToJobObject(job, process);
И третий слой, на всякий случай: при старте API ищем осиротевший postmaster.pid от прошлого аварийного запуска и убиваем его — но только если исполняемый файл процесса лежит именно в нашей распакованной директории инстанса (иначе легко зацепить чужой системный процесс с переиспользованным PID) (Но вообще этот код не нужен - после всех шишек что мне пришлось набить - нужно было позакрывать старые процессы, но если сразу делать "правильно" - то этот фрагент не нужен):
if (modulePath is not null && modulePath.StartsWith(instanceDir, StringComparison.OrdinalIgnoreCase)) { proc.Kill(entireProcessTree: true); proc.WaitForExit(5000); }
Три слоя ради одной гарантии выглядят избыточно, пока не вспомнишь, сколько раз процесс умирает не так, как задумано: краш, OOM, форс-килл из IDE. Ну и отдельно: весь этот проект — только под Debug, в релизную сборку он не попадает вообще, через условный ProjectReference:
<!-- backend/src/QoyHunter.Api/QoyHunter.Api.csproj --> <ItemGroup Condition="'$(Configuration)' == 'Debug'"> <ProjectReference Include="..\QoyHunter.LocalDb\QoyHunter.LocalDb.csproj" /> </ItemGroup>
Так dev-only зависимости (сама библиотека embed и её транзитивный SharpCompress для распаковки архивов) физически не попадают в прод-образ.
3. Telegram как настоящий OAuth2/OIDC-провайдер для веб-входа
Внутри Telegram Mini App вход бесшовный: приложение получает подписанный initData от telegram-web-app.js, бэкенд проверяет HMAC по токену бота — и пользователь просто оказывается внутри без единой формы. Но у нас есть и обычный веб — блог и часть посадочных страниц, — где человек открывает ссылку в обычном браузере, вне Telegram, и initData взять неоткуда.
Оказалось, у Telegram для этого случая есть готовый OAuth2/OIDC-провайдер — oauth.telegram.org, с client_id, равным id бота, authorization_endpoint, token_endpoint и JWKS для проверки подписи id_token. То есть с точки зрения протокола Telegram здесь ведёт себя ровно как Google или GitHub. Реализовали стандартный Authorization Code + PKCE (S256): раз client_secret обязан жить только на сервере, а PKCE защищает конкретно от перехвата кода на клиенте.
// backend/src/QoyHunter.Api/Auth/TelegramOidcLoginService.cs public TelegramOidcStart Start(string? returnTo = null) { var oidc = Oidc; var state = TelegramOidcPkce.NewState(); var verifier = TelegramOidcPkce.NewCodeVerifier(); var challenge = TelegramOidcPkce.CodeChallenge(verifier); var envelope = new StateEnvelope(state, verifier, DateTimeOffset.UtcNow.Add(StateTtl).ToUnixTimeSeconds(), normalizedReturn); var cookieValue = _protector.Protect(JsonSerializer.Serialize(envelope)); var query = new Dictionary<string, string?> { ["client_id"] = oidc.ClientId, ["redirect_uri"] = oidc.RedirectUri, ["response_type"] = "code", ["scope"] = "openid profile", ["state"] = state, ["code_challenge"] = challenge, ["code_challenge_method"] = "S256", }; var url = QueryHelpers.AddQueryString(oidc.AuthorizationEndpoint, query); return new TelegramOidcStart(url, cookieValue); }
State и code_verifier не идут в отдельную таблицу БД (не хотелось заводить сущность ради десятиминутного TTL) — они зашифрованы через ASP.NET Core Data Protection и лежат в HttpOnly-cookie, scoped на путь /api/auth/telegram. На колбэке state из query сверяется с state из cookie через CryptographicOperations.FixedTimeEquals (обычное сравнение строк тут уязвимо к тайминг-атаке), после чего код меняется на id_token, токен проверяется по JWKS, а аккаунт ищется по sub (Telegram id) — не по username, потому что username в Telegram можно сменить или вообще не заводить.
Ещё один нюанс: тем же входом пользуются и приложение, и блог, а после логина каждый должен вернуться в себя, а не куда попало — состояние returnTo кладётся в тот же зашифрованный конверт и на колбэке решает, куда редиректить:
public string BuildSuccessRedirect(string bridgeCode, string? returnTo = null) => $"{(returnTo == "blog" ? BlogBaseUrl() : AppBaseUrl())}/#b={Uri.EscapeDataString(bridgeCode)}";
Сама сессия у нас основана на паре refresh/access токенов, а не на cookie провайдера, поэтому после успешного OIDC-обмена сервер выдаёт одноразовый bridge-код во фрагменте URL (#b=...), который SPA сразу меняет на нашу обычную пару токенов — тот же механизм, что уже использовался для перехода из Telegram Mini App на сайт.
ИИ к слову не знает то что у Telegram есть OpenID вариант логина (Проверено с Codex 5.4 и Opus 4.6, сейчас возможно ситуация другая), поэтому не городите свой протокол «войти через Telegram на сайте» поверх виджета логина и просвятите своего агента вручную. Для веба у Telegram уже есть нормальный OIDC: PKCE, JWKS, стабильный sub — всё как у любого другого провайдера.
4. Один SPA-бандл на четыре с лишним домена
У продукта несколько сайтов: лендинг на корневом домене, витрина животных на bazar., сервис доставки на dostavka., блог на blog. — плюс закрытые служебные поддомены. Все они — по сути один и тот же React-бандл, который на рантайме смотрит на window.location.hostname и решает, какой раздел рисовать:
// frontend/src/lib/hosting.ts export function isAppHost(hostname: string): boolean { return firstLabel(hostname) === 'app' } export function isDeliveryHost(hostname: string): boolean { return firstLabel(hostname) === 'dostavka' } export function isBazarHost(hostname: string): boolean { return firstLabel(hostname) === 'bazar' }
Один бандл — меньше дублирования сборки и деплоя. Да и по собственному опыту агенты лучше работают с монолитами, и они удобнее для разработки в одиночку, всякие (уже "немодные") микросервисы советую использовать только если вам прям нужны микросервисы. Но у него есть врождённая проблема с SEO: единственный index.html означает единственные <title>, <meta description>, canonical и JSON-LD на всех хостов сразу. В индексации это выглядело так: у сервиса доставки в выдаче красовался заголовок лендинга (или вообще английский текст — краулер ведь не носит куку выбора языка), у карточек маркета — generic-описание без привязки к домену.
Решение — постпроцесс сборки: отдельный скрипт после vite build читает готовый dist/index.html и штампует из него index-dostavka.html, index-bazar.html, index-blog.html с собственными title/description/canonical/OG/JSON-LD для каждого хоста:
// frontend/scripts/build-seo-pages.mjs const HOSTS = [ { file: 'index-dostavka.html', url: 'https://dostavka.qoyhunter.com/', title: 'Доставка животных по Узбекистану — QoyHunter', description: 'Перевозка баранов, овец и сельхозживотных по всему Узбекистану...', jsonLd: { '@context': 'https://schema.org', '@type': 'Service', /* ... */ }, }, // index-bazar.html, index-blog.html — аналогично ] function setMeta(html, { url, title, description, jsonLd, lang }) { return html .replace(/<title>[^<]*<\/title>/, `<title>${title}</title>`) .replace(/(<meta\s+name="description"\s+content=")[^"]*(")/s, `$1${description}$2`) .replace(/(<link rel="canonical" href=")[^"]*(")/, `$1${url}$2`) // ... og:*, twitter:*, hreflang, JSON-LD }
А дальше nginx на каждом server-блоке отдаёт свой файл вместо общего index.html:
# deploy/nginx/https.conf — блок dostavka.qoyhunter.com index index-dostavka.html; location / { try_files $uri $uri/ /index-dostavka.html; } location = /index-dostavka.html { add_header Cache-Control "no-cache"; }
Файл с бандлом на диске один — просто у краулера, зашедшего на разные хосты, разный вход в SPA. Здесь же всплыл смежный баг: язык интерфейса изначально хранился в localStorage, а localStorage не расшаривается между поддоменами браузером — переход с лендинга на витрину или в доставку каждый раз сбрасывал выбранный узбекский обратно на язык браузера. Почему? localStorage привязан к origin (схема+хост+порт), а у bazar.qoyhunter.com и dostavka.qoyhunter.com origin разный, хотя домен вроде бы «один и тот же». Чинится куки на корневом домене:
// frontend/src/i18n/index.ts const cookieDomain = typeof window === 'undefined' ? undefined : rootHost(window.location.hostname) detection: { order: ['querystring', 'cookie', 'localStorage', 'navigator'], lookupCookie: 'qoy_lang', cookieDomain, cookieMinutes: 60 * 24 * 365, }
rootHost срезает известные поддомены сервисов (www., bazar., dostavka., blog. и другие), и cookie с Domain=qoyhunter.com видна на всех них сразу — в отличие от localStorage, который живёт per-origin.
Мультидоменная архитектура на одном бандле экономит на дублировании сборки. Расплата: всё, что браузер традиционно считает «per-origin» (localStorage, часть cookie без явного Domain), приходится руками поднимать на уровень корневого домена.

5. SSR блога бэкендом поверх статики фронта
У лендинга, витрины и доставки контент известен на момент сборки — поэтому SEO-страницы можно пререндерить постпроцессом, как в предыдущей истории. У блога контент появляется уже после того, как фронт собран и задеплоен: редактор публикует новый пост через админку, и было бы странно пересобирать весь SPA-бандл ради каждой статьи.
Решение — рендерить страницы блога не постпроцессом сборки, а бэкендом на лету, при этом переиспользуя тот же HTML-шаблон, что и у SPA (со всеми хешированными путями к JS/CSS бандлам после Vite build). Шаблон читается из файла на диске:
// backend/src/QoyHunter.Api/Blog/BlogRenderService.cs private string? Template() { if (_templateCache is not null) return _templateCache; var path = config["Blog:TemplatePath"]; if (string.IsNullOrEmpty(path) || !File.Exists(path)) { logger.LogWarning("Blog: шаблон не найден по пути {Path} — серверный рендер отключён", path); return null; } _templateCache = File.ReadAllText(path); return _templateCache; }
А путь до этого файла в проде — не файл, скопированный в образ API при сборке (у бэкенда в Docker-контексте нет каталога с фронтовыми исходниками), а смонтированный docker-том, тот же самый ./web, который раздаёт nginx:
# deploy/docker-compose.yml, сервис api Blog__TemplatePath: "/app/webroot/index-blog.html" volumes: - ./web:/app/webroot:ro
То есть контейнер бэкенда читает как read-only тот же артефакт фронтовой сборки, что и веб-сервер, и на его основе собирает готовый HTML: заголовок, описание, canonical, hreflang и JSON-LD Article — в <head>, а сам пост (Markdown → HTML через Markdig, с DisableHtml(), чтобы автор поста не мог вставить <script>) — прямо в <div id="root">:
html = html.Replace("<div id=\"root\"></div>", $"<div id=\"root\">{rootContent}</div>");
Дальше nginx проксирует «страничные» пути блога (/, /p/{slug}, языковые префиксы) на этот эндпоинт бэкенда, а при любой ошибке — 404, 500 или API временно недоступен — откатывается на обычный SPA-фоллбек через error_page ... = @spa:
location ~ ^/p/(?<slug>[a-z0-9-]+)$ { proxy_pass http://api:5080/api/blog/render/post/$slug?lang=ru; proxy_intercept_errors on; error_page 404 500 502 503 = @spa; }
Краулер в результате получает честный серверный HTML со своим <title> и JSON-LD Article на каждый пост (что критично для индексации и сниппетов), а живой пользователь после первой отрисовки получает тот же JS-бандл, который дальше подхватывает комментарии и реакции как обычное SPA. Гибрид «SSR только там, где решает SEO, SPA — во всём остальном» вместо полноценного Next.js-подобного фреймворка на React ради одного раздела сайта.
6. Тихие баги нормализации: часовые пояса и региональные локали
Две истории из одной категории — обе о том, что бэкенд и браузер расходятся в базовых допущениях, если их не свести явно.
Часовой пояс. Сервер хранит и отдаёт всё в UTC — индустриальный стандарт, ничего необычного. Но там, где важна граница календарных суток (по какому дню группировать взвешивания, на какой день ставить напоминание «сегодня»), UTC — плохой выбор: полночь по UTC не совпадает с полночью в Ташкенте, и запись, сделанная в 23:30 по местному времени, «уезжает» на следующий день при наивном приведении. Поэтому я завел единый статический класс-помощник, который резолвит пояс один раз при старте:
// backend/src/QoyHunter.Api/Configuration/AppClock.cs public const string DefaultTimeZoneId = "Asia/Tashkent"; public static readonly TimeSpan DefaultOffset = TimeSpan.FromHours(5); public static TimeZoneInfo Resolve(string? timeZoneId) { var id = string.IsNullOrWhiteSpace(timeZoneId) ? DefaultTimeZoneId : timeZoneId.Trim(); try { return TimeZoneInfo.FindSystemTimeZoneById(id); } catch (TimeZoneNotFoundException) { } catch (InvalidTimeZoneException) { } // Фолбэк на фиксированное +5: Ташкент не переходит на летнее время, // поэтому фолбэк совпадает с реальным поясом по смещению на любой ОС. return TimeZoneInfo.CreateCustomTimeZone("Asia/Tashkent (+05)", DefaultOffset, "Asia/Tashkent", "Asia/Tashkent"); } public static DateOnly Today() => LocalDate(DateTimeOffset.UtcNow);
Фолбэк на «жёсткое +5» здесь осознанный: узбекский часовой пояс не знает перехода на летнее время, поэтому даже если на сервере урезанная tz-база (бывает в минимальных Linux-образах) и Asia/Tashkent не резолвится по имени, фиксированное смещение остаётся корректным всегда, а не только «пока не наступит переход на летнее время». Это единственное место в бэкенде, где день считается «сегодня» — для напоминаний, дефолтных дат операций, выгрузок CSV и бота. А вот в UI то же самое время намеренно показывается в часовом поясе браузера пользователя, а не Ташкента — потому что часть пользователей открывает CRM из соседних стран с другим смещением, и это уже пояс самого человека, а не фермы.
Региональная локаль. Отдельный от часовых поясов, но похожий по природе баг: у части пользователей браузер определяет язык интерфейса не как ru, а как ru-RU (или uz-Latn) — с региональным субтегом. Мы передавали этот код напрямую в Intl.NumberFormat/toLocaleDateString, и для части экзотических комбинаций (или урезанных ICU-данных окружения) форматирование съезжало в root-фоллбек: даты вида «2026 M07 02» вместо «02 июл. 2026», числа без разделителей разрядов. Фикс — нормализовать язык до базового подтега перед тем, как отдавать его в Intl: (Тут решение неидеальное, но я решил пойти в лоб, а так реализовать можно по разному)
// frontend/src/lib/format.ts export function baseLang(lang: string | undefined | null): 'ru' | 'uz' | 'en' { const base = (lang ?? '').toLowerCase().split('-')[0] return base === 'ru' ? 'ru' : base === 'en' ? 'en' : 'uz' } export function localeOf(lang: string): string { const base = baseLang(lang) return base === 'ru' ? 'ru-RU' : base === 'en' ? 'en-US' : 'uz-UZ' }
И то же самое — уже на входе, в детекторе языка i18next, чтобы в куку qoy_lang не осело значение с регионом:
// frontend/src/i18n/index.ts detection: { convertDetectedLanguage: (lng) => lng.toLowerCase().split('-')[0] ?? lng, }
Оба бага объединяет одно: браузер и ОС дают «сырые» значения (locale с регионом, время в своём поясе), и слой форматирования обязан явно нормализовать их, а не полагаться, что Intl и DateTimeOffset сами разберутся.
7. Один <path> без трансформов — потому что так проще парсить программно
Короткая история о том, как декоративная задача (наложить фирменный силуэт барана в центр QR-кода на бирке животного) уткнулась в неожиданное техническое ограничение.
QR-коды на бирках печатаются с уровнем коррекции ошибок H (30% избыточности), поэтому в центр можно спокойно врисовать небольшой брендовый бейдж — сканер всё равно считает код. Композиция делается на бэкенде через SkiaSharp (та же нативная зависимость, что уже тянется транзитивно через QuestPDF и ScottPlot, — версию SkiaSharp в .csproj зафиксировали явно именно поэтому, чтобы не «плавала»):
// backend/src/QoyHunter.Api/Animals/QrLogoOverlay.cs using var path = SKPath.ParseSvgPathData(LogoPathData); if (path is not null) { var inner = badge * 0.72f; var scale = inner / LogoViewBox; canvas.Translate(cx - inner / 2f, cy - inner / 2f); canvas.Scale(scale); canvas.DrawPath(path, markPaint); }
Загвоздка в SKPath.ParseSvgPathData: он парсит ровно один атрибут d одного <path> — не файл SVG целиком. А исходный логотип, отрисованный в векторном редакторе, — это набор из нескольких <path> в группах со своими transform="translate(...)" на каждый, как обычно и выглядит экспорт из Illustrator/Figma (Или из png2svg сайтов).
Чтобы получить одну строку d, которую можно скормить парсеру и потом отмасштабировать вручную по известному viewBox, силуэт пришлось свести к единственному пути без трансформов: координаты «впечатать» прямо в узлы, а не оставлять в атрибуте группы.
/// <summary> /// Единый путь силуэта барана (атрибут d из logo_without_text_one_path_dark.svg, viewBox 0 0 512 512). /// Встроен константой — детерминированно, без файлового IO/парсинга XML при рендере. /// </summary> private const string LogoPathData = "M126 88c-44 7-83 40-89 86 0 4-2 39 1 41 2 2 1-4 1-6l1-14c2-15 8-29 18-41..."; private const float LogoViewBox = 512f;
Путь зашит константой в код, чтобы наложение логотипа не зависело от файлового I/O и XML-парсинга на горячем пути генерации бирки, и чтобы сборка не тянула лишний ассет в рантайм-образ.
Поверх — оборонительное программирование в духе «оформление не должно ронять функциональность»: любая ошибка при декодировании и наложении гасится, наружу уходит исходный QR без логотипа. Бирка обязана быть читаемой сканером в любом случае, бренд-бейдж — приятный бонус.
Вывод: если результат векторного редактора собираетесь скармливать не браузеру (который честно раскроет любые группы и трансформы), а минималистичному программному парсеру пути — закладывайтесь на то, что придётся руками сплющивать иллюстрацию в один плоский <path> с абсолютными координатами.
---
Результат, как это выглядит внешне можно увидеть на qoyhunter.com
В будущем планирую описать процесс подключения платежных систем, там тоже было немало подводных камней.
Если тема близка — про фермерскую сторону этой истории мы понемногу пишем в блоге.
