Мы делаем 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.LocalDbQoyHunter.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(/(<metas+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
В будущем планирую описать процесс подключения платежных систем, там тоже было немало подводных камней.
Если тема близка — про фермерскую сторону этой истории мы понемногу пишем в блоге.
Автор: AlexMorOR


