Вывод товаров по 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)
bezumkinВасилий Наумкин
07.09.2023 10:59

Судя по всему, это какой-то глюк Clockwork, из-за хитрой логики обработки контроллера.

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

Вот, записал на видео - через Xdebug одна остановка за обработку.

bezumkin
Василий Наумкин
01.03.2024 04:30
С PWA пока не разбирался, мне кажется это не особо популярная штука. Если надо просто иконки добавит...
bezumkin
Василий Наумкин
22.02.2024 09:23
На здоровье! Держи лайк =)
inetlover
Александр Наумов
27.01.2024 00:06
Василий, спасибо! Извини, тупанул.
bezumkin
Василий Наумкин
22.01.2024 04:43
Давай-давай!
bezumkin
Василий Наумкин
24.12.2023 11:26
Спасибо!
bezumkin
Василий Наумкин
27.11.2023 02:43
Ура!
bezumkin
Василий Наумкин
25.11.2023 08:30
Vesp тянет 2 зависимости: vesp-frontent для фронта и vesp-core для бэкенда. Их можно обновлять, но э...
bezumkin
Василий Наумкин
22.11.2023 08:09
Отлично, поздравляю!
bezumkin
Василий Наумкин
04.11.2023 10:31
На здоровье!
bezumkin
Василий Наумкин
30.10.2023 01:21
Спасибо!