Вывод товаров по uri

Сегодня мы обновляем публичный сайт с выводом товаров.
Нам нужно добавить работу с переводами, утилиты для вывода переводов и цен, а также научиться выводить товары по адресам с неизвестной глубиной вложения.
В предыдущей версии у нас вообще не было вложенных категорий, поэтому структура сайта была чётко прописана: категория и в ней товар. Сейчас же может быть категория\категория\категория\товар - и поэтому вывод товаров должен стать гораздо гибче.
Для начала просто починим вывод текущего каталога, который сломался из-за отсутствия колонки title - она теперь в отдельной таблице. Мы можем или присоединять таблицу переводов в контроллере, чтобы сохранить сортировку, или поменять её на дату публикации.
Я выбираю второй вариант, потому что сейчас нам нужно только починить вывод, а дальше мы его доработаем.
Меняем title на created_at в следующих файлах:
  • site/pages/index.vue
  • site/components/list-products-actions.vue
И вывод товаров уже работает:

Подключаем утилиты

Товары выводятся, но у них нет названий. Это потому, что наш публичный сайт вообще не поддерживает мультиязычность.
Настраиваем site/nuxt.config.js:
  • Подключаем все нужные модули
    Config.modules = [...Config.modules, '@vesp/frontend']
  • Раз мы подключили все модули, то настройку Config.Vesp можно просто удалить.
  • Добавляем загрузку утилит из плагина админки, и создаём новый плагин для сайта
    Config.plugins = ['../admin/plugins/utils.js', '~/plugins/utils.js']
  • Копируем настройки i18n из конфига админки
    Config.i18n.vueI18n = '@/lexicons/index.js'
    Config.i18n.defaultLocale = 'ru'
    Config.i18n.locales = [
    {code: 'ru', title: 'Русский'},
    {code: 'en', title: 'English'},
    ]
  • Создаём соответствующие файлы переводов, в которых просто реэкспортируем данные из админки.
frontend/src/site/lexicons/ru.js:
import Ru from '../../admin/lexicons/ru.js'

export default Ru
И создаём пустой плагин сайта: frontend/src/site/plugins/utils.js:
export default ({app}, inject) => {
}
Смысл в том, чтобы использовать ту же логику, что и в адмике, без дублирования. А всё новое мы напишем в свой плагин.
Перед выводом названий товаров, нужно их подключить в контроллере Controllers/Web/Products.php.
    protected function beforeGet(Builder $c): Builder
    {
        // ...
        $c->with('translations');
        $c->with('category:id,uri,active', 'category.translations:category_id,lang,title');

        return $c;
    }

    protected function afterCount(Builder $c): Builder
    {
        $c->with('translations:product_id,lang,title');
        $c->with('category:id,uri,active', 'category.translations:category_id,lang,title');
        $c->with('firstFile');

        return $c;
    }
Теперь мы можем использовать методы $translate и $price в компоненте components/list-products.vue.
Названия и цены выводятся как в админке. Остальные элементы страницы пока не переводим.

Универсальный контроллер

В отличие от предыдущей версии, мы не знаем возможную глубину вложения категорий, она ничем не ограничена, поэтому нам нужен универсальный маршрут.
Согласно документации Nuxt 2, такой маршрут создаётся файлом с именем _.vue.
Чтобы отделить вывод товаров магазина от других страниц, предлагаю добавить его в директорию products:
<template>
  <div>
    {{ $route.name }}
    {{ $route.params }}
  </div>
</template>

<script>
export default {
  name: 'ProductsDynamicPage',
}
</script>
Дальше на этой странице мы можем прописать всё нужное для загрузки товаров магазина.
Чтобы не вспоминать, на какой виртуальной странице у нас выводятся товары, я добавил в .env новую переменную PRODUCTS_PREFIX=products, которую буду использовать для генерации ссылок на конечные товары и категории.
Создаём метод productLink в утилитах:
export default ({app}, inject) => {
  inject('productLink', ({uri}) => {
    return uri ? ['', app.$config.PRODUCTS_PREFIX, uri].join('/') : ''
  })
}
И теперь с главной страницы мы будем попадать на новую страницу товара, используя новую функцию
 <b-link :to="$productLink(product)" ...>
Из этого адреса мы не cможем понять, запрашивается категория или товар. Поэтому вместо запроса конкретного контроллера из API нам нужен какой-то универсальный, который выдаст ответ только по uri.
Пишем очень интересный Controllers/Web/Resource.php:
<?php

namespace App\Controllers\Web;

use App\Models\Category;
use App\Models\Product;
use Psr\Http\Message\ResponseInterface;
use Slim\App;
use Slim\Psr7\Factory\ServerRequestFactory;
use Vesp\Controllers\Controller;
use Vesp\Services\Eloquent;

class Resource extends Controller
{
    private App $app;

    // Используем dependency injection, чтобы получить 
    // экземпяр текущего приложения
    public function __construct(App $app, Eloquent $eloquent)
    {
        parent::__construct($eloquent);
        $this->app = $app;
    }

    public function get(): ResponseInterface
    {
        // Получаем основные параметры
        $uri = trim($this->getProperty('uri', ''), '/');
        $endpoint = trim($this->request->getRequestTarget(), '/');
        // Инициализируем новую фабрику для запросов
        $factory = new ServerRequestFactory();

        // Проверяем, есть ли товар с таким uri, или категория
        if (Product::query()->where('uri', $uri)->count()) {
            $endpoint = str_replace('/resource/', '/products/', $endpoint);
            $request = $factory->createServerRequest('GET', $endpoint);
        } elseif (Category::query()->where('uri', $uri)->count()) {
            $endpoint = str_replace('/resource/', '/categories/', $endpoint);
            $request = $factory->createServerRequest('GET', $endpoint);
        } else {
            // Если ни товара, ни категории не найдено - выходим
            return $this->failure('Not Found', 404);
        }

        // Добавляем полученные заголовки в новый запрос
        foreach ($this->request->getHeaders() as $key => $value) {
            $request = $request->withHeader($key, $value);
        }

        // И передаём приложению наш запрос на обработку
        return $this->app->handle($request);
    }
}
Как видно, здесь мы проверяем наличие товаров и категорий по uri и подменяем запрос для загрузки нужного контроллера, делая вид, что обращение было по его адресу. Очень просто и красиво.
Контроллеры Resource, Products и Categories теперь должны принимать uri с качестве параметра. Причём, uri должен работать вместе со всеми слешами.
Находим, как указать такое в документации FastRoute - это {uri:.+}:
$group->group(
    '/web',
    static function (RouteCollectorProxy $group) {
        $group->any('/resource/{uri:.+}', App\Controllers\Web\Resource::class);
        $group->any('/categories[/{uri:.+}]', App\Controllers\Web\Categories::class);
        $group->any('/category/{category_id}/products', App\Controllers\Web\Category\Products::class);
        $group->any('/products[/{uri:.+}]', App\Controllers\Web\Products::class);
        $group->any('/orders', App\Controllers\Web\Orders::class);
    }
);
Контроллер Resource требует обязательного указания uri, а у Categories и Products оно опционально. Заодно я поменял маршрут для вывода товаров категории на более логичный, плюс, он не будет путаться с общим адресом категорий.
Меняем в 2х контроллерах определение первичного ключа:
    protected function getPrimaryKey(): ?array
    {
        if ($uri = $this->getProperty('uri')) {
            return ['uri' => $uri];
        }

        return null;
    }
Бэкенд готов для запросов - мы получаем разные данные, в зависимости от запрошенного uri.

Вывод товара

Редактируем нашу универсальную страницу, добавляя загрузку данных:
export default {
  name: 'ProductsDynamicPage',
  validate({params, route, redirect}) {
    // Если запрос заканчивается на слэш, редиректим на адрес без слэша
    if (route.fullPath.endsWith('/')) {
      return redirect(route.fullPath.slice(0, -1))
    }
    // И проверяем, есть ли вообще какой-то uri
    return Boolean(params.pathMatch)
  },
  async asyncData({app, params, error}) {
    // Запрос в API
    try {
      const {data} = await app.$axios.get('web/resource/' + params.pathMatch)
      return {record: data}
    } catch (e) {
      error({statusCode: e.response.status, message: e.message})
    }
  },
data() {
    return {
      record: {},
    }
  },
  computed: {
    isCategory() {
      return this.record.category_id === undefined
    },
  },
}
Шаблон меняем для вывод этих данных
<template>
  <div>
    {{ String(isCategory) }}
    <pre>{{ record }}</pre>
  </div>
</template>
Проверяем:
Дальше дело техники - рендерить разное содержимое страницы, в зависимости от того, категория это или товар.

Вывод товаров

Выносим содержимое страницы товара в новый компонент site/components/product.vue с небольшими изменениями: добавляем вывод переводов и правильных ссылок. Также меняем и компонент breadcrumbs.
Шаблон универсальной страницы теперь выглядит так:
<template>
  <div>
    <product v-if="!isCategory" :product="record" />
    <pre v-else>{{ record }}</pre>
  </div>
</template>
Подобным образом выносим и pages/_category/index.vue в отдельный копонент category.
<template>
  <div>
    <breadcrumbs v-if="category" :category="category" />

    <list-products-actions v-model="listView" :sort.sync="sort" :dir.sync="dir" />

    <list-products
      v-model="page"
      :category="category ? category.id : null"
      :limit="limit"
      :sort="sort"
      :dir="dir"
      :list-view="listView"
      @load="onLoad"
    />

    <b-pagination v-if="total > limit" v-model="page" :total-rows="total" :per-page="limit" class="mt-5" />
  </div>
</template>
Делаем проверку на наличие категории при выводе хлебных крошек, чтобы можно было вызывать <category /> и на главной странице:
<template>
  <category />
</template>

<script>
import Category from '~/components/category.vue'

export default {
  name: 'IndexPage',
  components: {Category},
}
</script>
Директорию pages/_category со вложенными файлами можно удалять.
На сайте осталось всего 2 страницы:
  • pages/index.vue
  • pages/products/_.vue
И этого пока достаточно для вывода всего каталога.
Если мы напрямую зайдём на страницу /products, то будет ошибка, потому что uri не указан. Чтобы такого не случилось, копируем index.vue внутрь pages/products - получается 3я страница:
  • pages/products/index.vue
Она будет корневой для каталога, тем более, что главную страницу обычно используют для вывода новостей, акций и всего такого.

Заключение

Я не стал описывать вообще все изменения, но вы сможете найти их в репозитории. В основном они касаются вывода переводов там и сям.
В итоге мы починили вывод товаров и научились работать с бесконечной глубиной вложения категорий. Остаётся еще вопрос, как выводить товары мультикатегории, и на какую грлубину выводить товары из подкатегорий - но это оставим на потом.
Итоговый коммит в репозитории.

3 комментария

Дмитрий П.
А так должно быть, что через контроллер Resource делается много повторяющихся запросов в базу, нежели чем через обычный контроллер?
Вот запросы контроллера Resource
А вот это уже контроллера Categoreis
Василий Наумкин
Судя по всему, это какой-то глюк Clockwork, из-за хитрой логики обработки контроллера.
Потому что видно, что одинаковые запросы повторяются несколько раз, но если врубить Xdebug, и поставить точку на один из методов контроллера категорий - то получает, что контроллер срабатывает только один раз.
Вот, записал на видео - через Xdebug одна остановка за обработку.
Screen Recording 2023-09-07 at 17.55.47.mov
7.61 MB, video/quicktime
Дмитрий П.
ну да, что-то на этот момент я не обратил внимание) видимо дублирование в девтулсах происходит из-за того, что при перенаправлении запроса Middlewere-ы, среди которых и Clockwork, тоже запускаются второй раз
bezumkin.ru
Личный сайт Василия Наумкина
Прямой эфир
Василий Наумкин
03.12.2024, 13:13:34
Генерация - это создание статичный файлов, для их работы потом pm2 не нужен, только правильная настр...
Василий Наумкин
22.11.2024, 03:33:54
Спасибо!
inna
06.11.2024, 15:47:13
Да. Все работает. Спасибо.
Василий Наумкин
01.07.2024, 11:56:41
Да, верно, именно так. А в контроллере, скорее всего, ловить данные методом post.
Василий Наумкин
26.06.2024, 09:38:15
О, точно, вылезает если не залогинен. Спасибо, исправил!
Василий Наумкин
09.04.2024, 04:45:01
> Ошибка 500 Это не похоже на ошибку Nginx, это скорее всего ошибка PHP - надо смотреть его логи. ...
Василий Наумкин
20.03.2024, 21:21:52
Volledig!
Андрей
14.03.2024, 13:47:10
Василий! Как всегда очень круто! Моё почтение!
russel gal
09.03.2024, 20:17:18
> А этот стоило написать хотя бы затем, чтобы получить комментарий от юзера, который ничего не писал...
Александр Наумов
27.01.2024, 03:06:18
Василий, спасибо! Извини, тупанул.