Перейти к основному содержимому

Домен: современный каталог

Современный каталог — это публичный фильтруемый список образовательных продуктов. По умолчанию он живёт на /catalog, но реальный prefix задаётся переменной MODERN_CATALOG_URL через config/systematika.php.

Что делает каталог

Каталог решает четыре задачи:

  • превращает URL в состояние фильтров;
  • выбирает продукты из БД по этому состоянию;
  • строит SEO title/description/page title для выбранной комбинации;
  • даёт пользователю менять фильтры без полной ручной навигации по страницам.

Основные файлы

ЗонаФайлы
Route registrationapp/Providers/Frontend/ModernCatalogServiceProvider.php
Controllerapp/Http/Controllers/ModernCatalogController.php
State buildersapp/Domains/ModernCatalog/State/*
Query builderapp/Domains/ModernCatalog/QueryBuilder.php
Route builderapp/Domains/ModernCatalog/RouteBuilder.php
Filter entitiesapp/Domains/ModernCatalog/Filters/EntitiesBuilder.php
SEO buildersapp/Domains/ModernCatalog/SeoPhrases/*
Livewire shellapp/Livewire/ModernCatalog/Spa.php
Livewire filters/gridapp/Livewire/ModernCatalog/*
Main viewresources/views/pages/modern-catalog.blade.php
Component viewsresources/views/livewire/modern-catalog/*
Filter settings modelapp/Models/Landing/Catalog/CatalogFilter.php
SEO phrases modelapp/Models/Landing/Catalog/CatalogSeoPhrase.php

URL как состояние фильтров

Каталог использует slug-и в URL. Один сегмент URL может соответствовать одному фильтру. Несколько значений одного фильтра кодируются через +.

Примерно так:

/catalog/math/5-klass/kursy/start-soon/free

Внутри InitialStateBuilder URL-сегменты:

  • фильтруются от пустых значений;
  • разбиваются по +;
  • дедуплицируются;
  • ищутся в активных моделях по полю slug.

Какие модели активны для state, зависит от таблицы catalog_filters.

Активные фильтры

StateBuilder знает структуру возможных фильтров:

State keyFilter idМодель
subjectssubjectSubject
subsubjectssubsubjectSubsubject
gradesgradeGrade
productTypesproduct_typeProductType
productSubtypesproduct_subtypeProductSubtype
statusesstatusProductPageStatus
productGoalsproduct_goalProductGoal
productPriceStatusesproduct_price_statusProductPriceStatus

Фильтр участвует в каталоге только если в catalog_filters есть запись с этим id и active = true.

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

Дефолтная цена

В StateBuilder::modifyOutput() есть важное поведение: если фильтр productPriceStatuses активен, но пользователь ничего не выбрал, в state добавляется ProductPriceStatus::PAID.

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

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

QueryBuilder строит Eloquent query для Product.

Базовые условия:

  • products.active = true;
  • у продукта есть page;
  • у продукта есть actualProductOffering;
  • у продукта есть активная card.

Потом применяются фильтры:

State keyКак фильтрует
subjectsproducts.subject_id in (...)
gradesчерез связь grades уровня сложности
productTypesproducts.product_type_id in (...)
productSubtypesproducts.product_subtype_id in (...)
statusesproduct_offerings.page_status_id in (...)
productGoalsчерез many-to-many product_product_goal
productPriceStatusesчерез тариф актуального offering

Сортировка по умолчанию в Livewire Grid:

  • catalog_sort desc;
  • start_date asc.

catalog_sort требует join на product_page_statuses. start_date сортирует по product_offerings.start_date.

Продукт, карточка и страница

Чтобы продукт попал в каталог, нужны минимум три части:

  1. Product — основная сущность продукта.
  2. ProductOffering с actual = true — актуальный сезон/предложение.
  3. ProductCard с active = true — данные карточки для вывода.
  4. ProductPage — ссылка/мета-данные страницы продукта.

Если продукт есть в админке, но не виден в каталоге, сначала проверяйте эти условия.

ProductOffering как важный инвариант

ProductOffering содержит скрытую бизнес-логику:

  • при создании без academic_year_id подставляется текущий академический год;
  • при сохранении с status_id подтягиваются product_format_id и page_status_id из ProductOfferingStatus;
  • если offering становится actual, остальные offering этого продукта автоматически получают actual = false;
  • при изменении default_lesson_duration_minutes обновляются связанные product_lessons со старой длительностью.

Нельзя массово менять product_offerings SQL-ом, не понимая эти side effects.

SEO каталога

SEO строится через CatalogSeoPhrase и builders в app/Domains/ModernCatalog/SeoPhrases.

Модель CatalogSeoPhrase может быть привязана к комбинации:

  • subject;
  • subsubject;
  • grade;
  • product type;
  • product subtype;
  • product goal;
  • price status;
  • page status.

Поля:

  • title;
  • description;
  • page_title.

ModernCatalogController использует SeoPageTitleBuilder. Если title найден, controller пишет его в $GLOBALS, чтобы layout мог заменить <title> и related head tags.

Livewire SPA-поведение

Spa наследуется от BaseSpa.

Цикл работы:

  1. При mount state становится пустой коллекцией, если не передан.
  2. При browser popstate JS отправляет событие popstateCustom.
  3. BaseSpa вызывает processURL().
  4. Spa заново строит state из текущего URL.
  5. Фильтры отправляют setCatalogState.
  6. Spa пересобирает route через RouteBuilder.
  7. JS получает setUrl и меняет URL без полной перезагрузки.
  8. Spa отправляет setPageTitle, если SEO title найден.

Это не React/Inertia SPA. Это Livewire-driven partial SPA с server-rendered state.

Фильтры

Базовая логика фильтров в AFilter.

Что делает AFilter:

  • загружает активные CatalogFilter;
  • загружает сущности для фильтров через EntitiesBuilder;
  • хранит текущий state;
  • добавляет и удаляет значения;
  • учитывает multiple;
  • удаляет дочерние фильтры при изменении родителя;
  • строит ссылку для конкретного значения фильтра.

Родительские зависимости:

РодительДочерние фильтры
product_typeproduct_subtype, status
subjectsubsubject

Если меняется subject, выбранный subsubject сбрасывается. Если меняется product type, сбрасываются subtype и status.

Как добавить новый фильтр

Минимальный порядок:

  1. Создать или выбрать модель, у которой есть slug.
  2. Добавить описание фильтра в StateBuilder::$modelsStructure.
  3. Добавить mapping в AFilter::$mapFilterIdToStateId.
  4. Обновить EntitiesBuilder, если он не умеет получать сущности новой модели.
  5. Добавить метод whereXxx() в QueryBuilder.
  6. Добавить запись в catalog_filters.
  7. Добавить UI в нужный Livewire view, если фильтр должен отображаться.
  8. При необходимости добавить SEO-поля в CatalogSeoPhrase и builders.

Типичные причины “продукт не отображается”

  • products.active = false.
  • Нет ProductPage.
  • Нет ProductCard.
  • ProductCard.active = false.
  • Нет ProductOffering с actual = true.
  • У актуального offering нет нужного page_status_id.
  • Тариф не соответствует выбранному price status.
  • Фильтр active/multiple настроен не так, как ожидается.
  • URL содержит slug, который не найден ни в одной активной модели фильтра.

Админские точки управления

Что менятьГде в админке/коде
ПродуктыProductResource
Сезоны/offeringProductOfferingResource и relation manager продукта
ТарифыTariffResource
Типы продуктовProductTypeResource
Подтипы продуктовrelation manager в ProductTypeResource
Статусы страницыProductPageStatusResource
Статусы offeringProductOfferingStatusResource
Фильтры каталогаCatalogFilterResource
SEO фразыCatalogSeoPhraseResource
Карточка продуктавкладка “Представление” в ProductResource