Skip to content

Разработчику ​

Эта страница — для работы в IDE, с файлами, в git. Сниппеты вам не нужны: всё, что они умеют, доступно прямым вызовом, потому что сами они над этим вызовом и написаны.

Сервис ​

php
/** @var \Boshnik\PbShop\PbShop $shop */
$shop = $modx->services->get('pbshop');

$products = $shop->products()
    ->inCategory('mugs')
    ->sortBy('price', 'desc')
    ->limit(12)
    ->page(2)
    ->get();

products() отдаёт новый объект запроса на каждый вызов: у него есть состояние — раздел, предел, порядок, — и общий на всех означал бы, что второй список на странице получает условия первого.

Каталог ​

МетодЧто делает
inCategory($category)раздел: модель, id или псевдоним. Ноль — товары вне категорий
uncategorized()только то, что лежит прямо под каталогом
limit(?int)предел; ноль и отрицательное значат «без предела»
offset(int) / page(int)смещение или номер страницы
sortBy(string $field, string $dir = 'asc')порядок; несколько полей через запятую
where(array)условия по колонкам таблицы
withFilter(array $query, string $base)подбор по опциям из адреса
filter()сам фильтр — шаблону нужны его блоки, отмеченность и сброс
currency(string)в какой валюте считать цены строк
get()коллекция моделей
first()одна модель или null
lines()готовые строки для шаблона и JSON
total() / pages()сколько всего и сколько страниц
refusal()почему список пуст, если пуст не потому, что товаров нет
builder(bool $paged = true)построитель Eloquent как он есть

builder() публичный намеренно: если нужно условие, которого здесь нет, лучше дописать его к нашему запросу, чем собрать запрос с нуля и разойтись с нами в предзагрузке и в том, что считать выложенным.

categories() устроен так же: under(), all(), limit(), offset(), sortBy(), get(), first(), total(), refusal().

Постраничность ​

Support\Pagination считает окно страниц, а адрес приносит замыкание — у каталога это адрес текущего выбора фильтра (CatalogFilter::current()) плюс номер:

php
$pagination = new Pagination($products->total(), $limit, $page);

if ($pagination->beyond()) {
    return (new Response())->abort(404);
}

$base = $products->filter()?->current() ?? '/mugs';
$data = $pagination->toArray(fn(int $n) => $n <= 1 ? $base : $base . '?page=' . $n);

Три решения, которые стоит унаследовать, а не переигрывать: у первой страницы адрес без ?page=1 (иначе у одной страницы два адреса и две записи в кеше ответов), номер за пределом — 404, а не пустой список (иначе это сколько угодно адресов с одинаковым пустым содержимым), и адрес страницы собирает фильтр, а не вызывающий — он держит справочный порядок параметров, от которого зависит ключ кеша.

Остальное ​

ВызовЧто отдаёт
$shop->product($id)товар по псевдониму или id
$shop->category($id)категорию по псевдониму или id
$shop->order($uuid)заказ по uuid — тот же ключ, по которому его открывает покупатель
$shop->cart()CartService: состав, количество, промокод
$shop->currentCart()корзину того, кто пришёл: по cookie у гостя, по учётной записи у вошедшего
$shop->orders()OrderService: создание, продление, истечение, отмена
$shop->payments()PaymentService: создание платежа, разбор колбэка, сверка суммы, возврат
$shop->delivery()DeliveryService: способы под состав, расчёт, справочники служб
$shop->stock()StockService: резерв, возврат, освобождение брошенного
$shop->favorites(), $shop->compare()избранное и сравнение
$shop->promocodes()проверка кода и расчёт скидки
$shop->stockAlerts()подписки «сообщить о поступлении»
$shop->drivers()реестр платёжных драйверов
$shop->fiscal()касса 54-ФЗ; poll() добирает недопечатанные чеки

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

Отказ — не исключение

inCategory('нет-такого') не бросает и не падает: запрос запоминает причину, и её отдаёт refusal(). Список — это страница, и на опечатку она обязана ответить надписью, а не пятисотой. Печатает надпись вызывающий: у сниппета это текст вместо списка, у вашего контроллера — то, что вы решите.

Роут, контроллер, шаблон ​

Ни одного вызова сниппета. Маршруты регистрируются в site-owned core/App/routes/, компонент туда ничего не кладёт.

php
// core/App/routes/web.php
use Boshnik\PageBlocks\Facades\Route;

Route::get('mugs', [App\Http\Controllers\MugsController::class, 'index']);
php
// core/App/Http/Controllers/MugsController.php
namespace App\Http\Controllers;

use Boshnik\PageBlocks\Http\Request;
use Boshnik\PageBlocks\Http\Response;

class MugsController
{
    public function index(Request $request): Response
    {
        $shop = modx()->services->get('pbshop');

        $products = $shop->products()
            ->inCategory('mugs')
            ->currency('RUB')
            ->withFilter($request->query(), '/mugs')
            ->limit(24)
            ->page((int) $request->get('page', 1));

        if ($products->refusal() !== '') {
            return (new Response())->abort(404);
        }

        return (new Response())->html(view('file:mugs', [
            'products' => $products->get()->all(),
            'filter' => $products->filter()?->blocks() ?? [],
            'pages' => $products->pages(),
        ]));
    }
}

Шаблон — обычный Fenom из pageblocks_elements_path. Дефолтные чанки компонента вам не нужны, но доступны: {$product->line($currency)} отдаёт тот же плоский набор, которым рисует карточку сниппет.

События ​

Всё, чем сайт расширяет магазин. Слушатели регистрируются в site-owned core/App/config/pbshop.php, а не системными событиями MODX: те приходится руками прикреплять в менеджере после каждого деплоя.

php
// core/App/config/pbshop.php
return [
    'listeners' => [
        \Boshnik\PbShop\Events\Dispatcher::ORDER_PAID => [
            App\Listeners\NotifyWarehouse::class,
        ],
    ],
];

Слушатель — класс с методом handle(array $params, string $event) либо любой callable. Исключение в слушателе не роняет платёж: оно логируется, остальные слушатели отрабатывают.

pbShopPaymentCreate    pbShopPaymentSuccess    pbShopPaymentFail    pbShopPaymentRefund
pbShopOrderCreate      pbShopOrderPaid         pbShopOrderExtend    pbShopOrderExpire
pbShopOrderCancel      pbShopOrderStatus       pbShopOrderDelivered
pbShopWaybillCreate    pbShopWaybillStatus
pbShopStockAlertCreate pbShopStockAlertConfirm pbShopStockAlertReady

$modx->invokeEvent() вызывается дополнительно — для сторонних плагинов, — но получает только скаляры: объекты превращаются в {$key}_id.

Ответ слушателя почти нигде ничего не значит

Исключение одно: pbShopStockAlertReady. Слушатель, вернувший false, берёт рассылку на себя, и своего письма компонент тогда не шлёт — иначе магазин со своей рассылкой отправил бы покупателю два письма об одном.

JSON ​

Корзина, сравнение, избранное, доставка и оформление уже отвечают JSON — это те самые маршруты, которыми пользуется встроенная витрина:

POST   /pay/cart              состав корзины (не GET: кеш ответов не видит cookie)
POST   /pay/cart/add          положить товар или вариант
POST   /pay/cart/{id}         количество строки
DELETE /pay/cart/{id}         убрать строку
POST   /pay/cart/promo        промокод; пустой код — убрать
POST   /pay/delivery          способы доставки под состав
POST   /pay/checkout          оформление
POST   /pay/quick             покупка в один клик

Свой фронтенд берёт их как есть. Каталог и карточка отдают HTML, а не JSON: чтобы получить их данными, напишите свой роут и верните $products->lines() — это тот же массив, что уезжает в шаблон.

Тесты ​

Тесты — самостоятельные скрипты, без PHPUnit и без поднятия фронта MODX. Каждый возвращает exit 1 при провале.

bash
php tests/catalog-query.php       # раздел, предел, порядок, подбор
php tests/snippet-output.php      # словарь параметров сниппетов
php tests/default-chunks.php      # политика обновления дефолтных чанков
php tests/templates-compile.php   # все встроенные шаблоны собираются Fenom
php tests/yookassa-callback.php   # разбор колбэка шлюза

Подписи колбэков сверяются с эталонной реализацией шлюза — новую подпись проверяйте тем же способом. Бизнес-логика (фильтр, резерв склада, скидка, тарифы доставки) тестируется на заглушках: базы для этого не нужно.

Границы, которые стоит знать ​

  • Правила конкретной площадки в компонент не едут. pbShop ставят на чужие проекты; логика заказчика живёт в site-owned core/App/.
  • Суммы везде целые в минорных единицах. Support\Money — единственное место, где происходит перевод в человекочитаемое и обратно.
  • Драйвер платежа обязан проверять подпись сам. PaymentService сверяет сумму сверху и сумму из колбэка на веру не принимает.
  • Расписание ведёт сайт. dueForRenewal(), renew(), expire(), fiscal()->poll() и рассылку подписок зовёт ваш крон.

pbShop — магазин для сайтов на PageBlocks