Кейс: свой магазин
ENRU
Кейс - интернет-магазин бренда одежды

Свой магазин для бренда, который живёт на маркетплейсе


У бренда своё швейное производство, архив из трёхсот с лишним принтов и весь товарный учёт внутри кабинета маркетплейса. Магазин собран так, чтобы каталог не пришлось вести дважды: 64 модели и 307 расцветок приезжают из кабинета продавца сами, цена пересчитывается трижды в сутки по правилу владельца, а всё, что сотрудник поправил руками, синхронизация больше не трогает. Витрина, админка, заказы, уведомления в Telegram. Эквайринг вынесен в отдельный этап.

Next.js 15React 19TypeScriptPrisma 6 PostgreSQL 17Tailwind v4VitestDocker
307 расцветок
в 64 моделях, 12 разделов каталога
4 478
кадров перенесено к себе с маркетплейса
1 664
автотеста, Vitest
27
моделей данных
25
экранов админ-панели
01 Задача клиента

Магазин, который не заставляет вести каталог второй раз

Бренд с собственным цехом продаёт через маркетплейс. Там же лежат все карточки: названия, цены, характеристики, фотографии. Свой сайт нужен не вместо площадки, а рядом с ней: прямая продажа без комиссии, свои условия и своё лицо. Главное ограничение сформулировал сам владелец: вести каталог в двух местах он не будет. Значит, всё, что уже описано в кабинете продавца, сайт обязан забирать сам.

I.Двойной ввод убил бы проект

Триста расцветок, у каждой название, цена, размерный ряд и десяток кадров. Ручное заведение занимает недели и устаревает в тот же день, когда владелец поменяет цену на площадке.

II.Цены живут на площадке

Цена меняется акциями и переоценкой, иногда несколько раз в неделю. Сайт с прошлогодним прайсом хуже, чем отсутствие сайта: покупатель сравнивает и уходит.

III.Каталог не должен выглядеть как склад

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

IV.Владелец правит сайт сам

Без разработчика: описания, цены, порядок кадров, блоки главной, статусы заказов. И так, чтобы ночная синхронизация не затёрла сделанное руками.

Рамка проекта

Три этапа, честно разделённые

Этап 1: сайт, админка, первая загрузка каталога. Этап 2: синхронизация с маркетплейсом. Этап 3: оплата, доставка, интеграция со службой доставки. Кейс описывает первые два: они сделаны и работают на боевом сервере. Эквайринг не подключён намеренно, заказ оформляется без списания средств.

02 Архитектура

От кабинета продавца до витрины: четыре прогона и одна база

Приложение одно, Next.js на App Router. Всё, что связано с внешними системами, живёт в отдельных модулях и ходит наружу только по расписанию: витрина и админка читают свою базу, а не чужой API. Так страница не зависит от того, отвечает ли сейчас маркетплейс.

API маркетплейса карточки, цены, статистика токен только на чтение Складской учёт остатки по штрихкодам Хранилище кадров 4 478 фотографий ПРОГОНЫ ПО РАСПИСАНИЮ цены 07:10 / 15:10 / 23:10 каталог карточки, размеры, кадры заказы по одному дню, каждые 3 ч остатки со склада, по штрихкоду пауза по лимиту ложится в настройки пишут PostgreSQL 17 26 моделей, 26 миграций модель, расцветка, размер заказ, промо, настройки журнал прогонов и правок замки полей: правка человека сильнее читают Витрина каталог, фильтры, карточка корзина, заказ, кабинет Админ-панель заказы, товары, цены страницы, роли, синхронизация Telegram заказ в чат напоминания Правило, которое держит всю схему Наружу ходят только прогоны. Витрина и админка не знают ни одного внешнего адреса. Поэтому отказ маркетплейса не роняет страницу, а лимит кабинета не превращается в ошибку у покупателя.
внешние системы прогоны по расписанию своя база интерфейсы
312файлов TypeScript
47 221строка кода
37страниц приложения
9доменных модулей
73компонента UI
03 Флагманская часть

Синхронизация: три находки, каждая тихо портила каталог

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

  1. Ответ обрезается на восьмидесяти тысячах строк. Никакого признака вроде "есть ещё" в нём нет: единственная примета обрезки - что строк ровно столько. Месяц у работающего магазина набирает предел за полторы недели, и остальное просто не приезжает.
  2. Отбор идёт по дате изменения, а не по дате заказа. Заказ годовой давности со вчерашней сменой статуса приезжает наравне со вчерашним и поднимает товар наверх каталога. Лечится флагом, который отдаёт заказы именно указанного дня.
  3. Лимит считается на весь кабинет продавца, а не на ручку и не на приложение. Один тяжёлый запрос на месяц закрывает кабинет больше чем на час, и под этот же отказ попадают цены и остатки: ручки разные, лимит один.
Что было сломано

Отказ приходит с названным сроком, но не в том заголовке

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

1.Забираем по одному дню

Каждый прогон берёт день, которого в истории нет вовсе, а если такого нет - самый давно обновлявшийся. Прогон исходит из того, что успеет ровно один запрос, и тратит его на самый полезный день.

2.Месяц считается по своей истории

Дни складываются в собственную таблицу и живут дольше окна. Отказ по одному дню не обнуляет товар: день остаётся с прежней записью, а в журнал уходит, какие дни не дались.

3.Названный срок сильнее своей лесенки

Пауза больше пяти минут не ждётся внутри прогона: срок ложится в настройки, прогон заканчивается, а следующий по расписанию просто не пойдёт, пока время не выйдет. Удачный запрос снимает запрет.

Найдено при разборе

Тип токена меняет лимит в сто восемьдесят раз

У площадки на одну и ту же ручку разные квоты в зависимости от типа токена: у части токенов запрос в минуту, у базового - один запрос в три часа. Наш оказался базовым, отсюда и всё остальное: один удачный запрос, потом полтора часа отказов. Это не перегрузка и не чужие сервисы, а объявленный лимит. Владельцу это сформулировано одним действием: пройти идентификацию в кабинете или перевыпустить токен, и история заказов наберётся за дни, а не за месяц.

Экран синхронизации в админке: состояние расписания, пауза по лимиту кабинета, области и срок токена, журнал последних прогонов
Экран синхронизации отвечает на один вопрос: работает ли расписание. Кнопки "запустить" здесь нет намеренно - полная загрузка каталога идёт минуты и упирается в лимиты, а веб-запрос столько не живёт.
Почему это на экране, а не в логах

Лимит виден владельцу словами

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

04 Деньги

Цена сайта и ловушка скидки постоянного покупателя

Владелец хотел простого: на сайте дешевле, чем на площадке. Формулировка выглядит однозначной ровно до того момента, когда выясняется, какую именно цену видит покупатель на витрине маркетплейса.

+
Цена продавца после его скидкиединственная цена, которую API отдаёт честно
Скидка постоянного покупателяеё платит площадка, в API она недоступна, а на витрине видна
=
Витринная цена маркетплейсато, что покупатель сравнивает с нашей
!
"На 10% дешевле" технически означает "на 10% дешевле цены продавца"покупатель, сравнивая с витриной площадки, мог увидеть на сайте цену выше

I.У правила цены есть база

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

II.Правило лежит в настройках, а не в коде

Значение в коде - только запасное, на пустую базу. Правило задаёт владелец в админке, и его берут все три прогона. Если бы хоть один считал по коду, синхронизация возвращала бы цены к прежнему проценту, и правка отваливалась бы сама.

III.Непонятное поле не роняет прогон

Разбор правила молча заменяет испорченное значение запасным. Ночной прогон не должен вставать из-за опечатки в одном числе: иначе цены отстанут от площадки на сутки.

IV.Цены и популярность разведены

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

05 Каталог

Модель и расцветка: 307 карточек превращаются в 64 товара

Маркетплейс отдаёт отдельную карточку на каждый цвет. Ключ, который связывает цвета одной вещи, приезжает отдельным полем - по нему карточки собираются в модель, а внутри модели остаются расцветками с переключателем. Без этой группировки витрина выглядела бы как триста почти одинаковых футболок.

РешениеКак сделаноПочему так
Название расцветки берём из артикула продавца, характеристика - запасной вариант В характеристике "Цвет" площадка перечисляет все цвета принта: у футболки с лимонами там четыре значения. Это описание картинки, а не вариант.
Заголовок товара слово в слово как на площадке Покупатель, пришедший с маркетплейса, должен узнать вещь по названию. Короткая форма нужна ровно в одном месте - в адресе страницы.
Порядок расцветок по возрастанию идентификатора карточки Площадка отдаёт карточки в порядке обновления. Без явной сортировки цвета на витрине перемешивались бы после каждой синхронизации.
Кадры копия у себя, ссылка площадки остаётся ключом сопоставления Подменить ссылку локальным путём нельзя: ближайший прогон счёл бы все кадры пропавшими с карточки и удалил их вместе с расстановкой и отметками "скрыть".
Расстановка кадров синхронизация правит только состав, не порядок Порядок и скрытые кадры задал сотрудник. Раньше порядок переписывался каждым прогоном, и расстановка возвращалась к виду площадки за ночь.
Нулевой размер остаётся на витрине перечёркнутым Пропавший размер покупатель читает как "не для меня". Перечёркнутый говорит, что вещь такая есть и может вернуться.
Ключевой механизм

Замки полей: правка человека сильнее ночного прогона

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

Каталог: разделы с числом вариантов, фильтр по цвету кружками, буквенные и цифровые размеры, четыре сортировки, сетка карточек
Фасеты считаются в пределах выбранного раздела: в аксессуарах не бывает размера 4XL, и предлагать его там незачем.
Карточка товара: галерея с миниатюрами, переключатель расцветок, размеры с остатками, характеристики моноширинным набором
Переключатель расцветок внутри склейки, характеристики набраны моноширинным - как спецификация, а не как маркетинговый текст.
Оформление

Направление "цех и лист"

У бренда своё производство и архив из трёхсот с лишним принтов, поэтому сайт устроен как каталог образцов, а не как лукбук: белый лист, печатный синий единственным акцентом, нулевые радиусы. Три шрифта делят работу - голос бренда, текст и данные. Артикулы, размеры, плотность и имена принтов набраны моноширинным: в каталоге образцов так и надо. Фирменный элемент - лента принтов под первым экраном: имена расцветок едут строкой и проговаривают масштаб архива именами, а не цифрой.

Главная страница: полноэкранный кадр, заголовок строчными, лента имён принтов под первым экраном
Главная собирается из блоков, которые лежат в базе: владелец добавляет их, переставляет и настраивает в админке, а страница только читает по порядку.
06 Админ-панель

Инструмент владельца, а не панель разработчика

Разделы разные у разных ролей, и доступ решает каждая страница сама. Это не единственный рубеж: серверные действия проверяют разрешение ещё раз, потому что действие вызывается напрямую по HTTP, и проверка в оболочке при этом не выполняется вовсе.

I.Сводка

Сначала то, что требует действия: расцветки без цены, новые заказы, размеры с нулевым остатком. Потом общие цифры, и только те, что открыты роли: выручка - это деньги владельца, а не общая справка.

II.Заказы

Список, карточка, смена статуса только по разрешённым переходам. Заявки с формы обратной связи лежат в том же разделе: в них персональные данные, и отвечает на них тот же человек.

III.Товары

Поиск, правка названия и описания, цены по расцветкам, остатки по размерам, скрытие кадров и замки полей от перезаписи синхронизацией.

IV.Режим правки витрины

Рамка вокруг страницы, правка текста прямо на месте, кнопки сохранить и отменить. Отмена работает по журналу прежних значений, а не по памяти вкладки.

V.Конструктор главной

Блоки главной лежат в базе. Страница не знает, из чего она состоит: читает блоки по порядку и вычитывает разом всё, что им понадобится из каталога.

VI.Уведомления в Telegram

Заказ приходит в рабочий чат вебхуком. Очередь, напоминания и чистка идут отдельной командой из расписания каждые десять минут, чтобы недоступный мессенджер не ронял оформление заказа.

VII.Заказы: статус меняется в строке

Список отдаёт статус селектом прямо в строке: открывать карточку ради одного перехода не нужно. В карточке лежит то, ради чего её открывают: состав, доставка, платёж и история.

VIII.Контент одним пунктом

Главная, страницы, документы и соцсети собраны под один пункт меню. Раньше они висели четырьмя строками, и меню разъезжалось.

IX.Аналитика с электронной коммерцией

Два счётчика, воронка и цели. Просмотр товара считается один раз за заход, а не дважды: событие висит на карточке, а не на каждом рендере.

Раздел товаров в админке: модель с ведущим принтом, число расцветок, вилка цен, остаток, заказы за 30 дней с полосой, скрытые кадры и замок поля
Одна строка - модель, внутри неё расцветки. Замок значит, что поле правил сотрудник и ночная синхронизация его больше не трогает.
07 Оплата и доставка

Заказ доводится до денег и до двери

Оформление перестало заканчиваться письмом менеджеру. Покупатель выбирает службу доставки и пункт на карте прямо на чекауте, платит картой или быстрым платежом, а оператор печатает накладную из админки и видит статусы, которые приезжают сами.

Чекаут: выбор службы доставки, пункт на карте, способ оплаты и согласие на обработку данных
Чекаут: четыре способа доставки, выбор пункта с адресом и сроком, карта или быстрый платёж, согласие на обработку персональных данных галочкой.

I.Эквайринг банка

Оплата картой и быстрым платежом по QR. Уведомления банка приходят на свою ручку, подпись проверяется, и заказ меняет состояние только по подтверждённому уведомлению, а не по возврату покупателя на сайт.

II.Возврат денег

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

III.Три службы доставки

Курьерская сеть с пунктами на карте и накладной из админки, национальная почта с посылкой по индексу отделения и своей этикеткой, служба быстрой доставки с заявкой и статусами по опросу.

IV.Статусы приезжают сами

У одной службы вебхуки, у другой опрос по расписанию: у кого что есть. Оператор не ходит по кабинетам служб, а видит трек и состояние в строке заказа.

V.Отказ говорит словами службы

Если служба отказалась печатать этикетку, оператор видит её формулировку, а не голый код ошибки. Иначе разбор сводится к переписке с поддержкой, а заказ стоит.

VI.Согласие на обработку данных

Галочка на регистрации и на чекауте, отметка хранится вместе с заказом. Это не украшение: без неё нельзя ни писать покупателю, ни передавать адрес службе доставки.

Карточка заказа: покупатель, доставка с треком и этикеткой, оплата с возвратом, состав и история
Карточка заказа: покупатель, доставка с треком и этикеткой, оплата с кнопкой возврата, состав и история того, что происходило.
Журнал платежей: способ, статус, сумма и заметка
Журнал платежей: способ, статус, сумма и заметка. Отказ банка виден словами, а не кодом.
08 Как это построено

Код разложен по доменам, решения записаны рядом с кодом

9доменных модулей
312файлов TypeScript
1 356автотестов
73файла тестов
26миграций

I.Код разложен по доменам

Один модуль - одна папка, свой README, свои тесты рядом с кодом. Граница модуля это его индексный файл: импортировать внутренние файлы чужого модуля нельзя. Понадобилось - значит не хватает в публичном API, и добавлять надо туда.

II.README пишется до кода

В нём не то, что делает код - это видно из кода, - а какие решения приняты и почему. Половина находок из раздела про синхронизацию живёт именно там, и следующий человек не наступит на них заново.

III.Тесты рядом с кодом

Файл правил и файл тестов лежат в одной папке. Видно, что покрыто, а что нет, и тест переезжает вместе с кодом. Сеть в тестах не используется: HTTP-клиент принимает подмену снаружи.

IV.Отдельная тестовая база

Обвязка подменяет адрес базы до создания клиента и падает, если в адресе нет тестового имени. Без этой проверки неудачный запуск стёр бы каталог разработки.

V.Проверки идут на сервере

Тесты, типы и сборка прогоняются на отдельной копии проекта с отдельной базой. Локальная база разработчику больше не нужна, а боевой магазин при этом не трогается.

VI.Выкладка одной командой

Скрипт деплоя, сертификат с автопродлением, переадресация с www и с адреса сервера на основной домен. Расписание прогонов живёт в планировщике, а не внутри веб-запроса.

Про эти цифры

Тысяча триста тестов - это не героизм

Так получается, когда чистые правила отделены от работы с базой и сетью. Разбор карточки, расчёт цены, переходы статусов заказа, разбор правила цены, группировка расцветок - всё это функции без побочных эффектов, и тест на них стоит недорого. Дорогие сквозные проверки остаются там, где без них нельзя.

09 Результат

Было и стало

ЧтоБылоСтало
Каталогтолько в кабинете маркетплейса307 расцветок на своём сайте, обновляются сами
Ценыменяются на площадке, сайта нетпересчёт трижды в сутки по правилу владельца
Порядок товаров-по заказам за 30 дней, история набирается сама
Правка каталогатолько через кабинет площадкиадминка с замками полей, правка переживает ночь
Фотографиираздаются складом маркетплейса4 478 кадров лежат у себя
Заказы-оформление, статусы, уведомление в Telegram
Лимиты площадкиотказы выглядели как поломкаобъяснены на экране словами, прогоны ждут сами

Магазин работает на боевом сервере под своим доменом, с сертификатом и автопродлением. Каталог, цены и порядок товаров обновляются по расписанию без участия человека. Владелец правит витрину и заказы сам. Следующий этап - оплата и доставка - отделён намеренно и оформляется отдельным заданием.

Кирилл Седов

Бизнес-анализ и автоматизация: ERP, дашборды, интеграции с маркетплейсами и AI-агенты. Бывший операционный директор компании с оборотом 4 млн долларов в месяц, теперь строю такие системы под заказ.

Написать в LinkedIn