Выводим товары на сайте

Ну что, модели созданы, контроллеры отлажены, пора бы и вывести товары на нашем сайте.
Для удобной работы предлагаю использовать очень известную библиотеку Faker, которая позволит нам нагенерировать нужное количество товаров и категорий без лишних сложностей.
Устаналиваем fakerphp/faker зависимостью для разработки, на рабочем окружении она не будет нужна:
composer require fakerphp/faker --dev
Затем создаём новый seed файл в core/db/seeds/Products.php
<?php

use App\Models\Category;
use App\Models\Product;
use Phinx\Seed\AbstractSeed;
use Vesp\Services\Eloquent;

class Products extends AbstractSeed
{
    // Количество генерируемых категорий
    protected $categories = 100;
    // И товаров
    protected $products = 1000;

    public function run(): void
    {
        // Если вдруг Faker не установлен - то просто ничего не делаем
        if (!class_exists('Faker\Factory')) {
            return;
        }

        // Временно отключаем проверку в БД по foreign key
        (new Eloquent())->getConnection()->getSchemaBuilder()
            ->disableForeignKeyConstraints();
        // Чтобы можно было полностью очистить таблицы
        Category::query()->truncate();
        Product::query()->truncate();

        $faker = Faker\Factory::create();

        // Создаём 100 категорий
        for ($i = 0; $i < $this->categories; $i++) {
            Category::query()->create([
                'title' => 'Категория ' . ($i + 1),
                // Случайный lorem ipsum
                'description' => $faker->text,
                // Категория активна с вероятностью 90%
                'active' => random_int(0, 9) > 0,
            ]);
        }

        // Теперь 1000 товаров
        for ($i = 0; $i < $this->products; $i++) {
            Product::query()->create([
                'title' => 'Товар ' . ($i + 1),
                'description' => $faker->text,
                // Указываем 1 из 100 категорий случайным образом
                'category_id' => random_int(1, $this->categories),
                // Артикулом назначаем уникальное 9ти-значное число
                'sku' => $faker->unique()->randomNumber(9, true),
                // Цена тоже случайная, от 100.00 до 10000.99
                'price' => $faker->randomFloat(2, 100, 10000),
                // 10% товаров неактивны
                'active' => random_int(0, 9) > 0,
            ]);
        }
    }
}
При каждом запуске этого сида все товары и категории будут перезаписны случайными значениями.
Запускаем только наш новый сид:
composer db:seed-one Products
Вот теперь нам есть что выводить на сайте!

Web контроллеры

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

src/Controllers/Web/Categories.php

<?php

namespace App\Controllers\Web;

use App\Models\Category;
use Illuminate\Database\Eloquent\Builder;
use Vesp\Controllers\ModelController;

class Categories extends ModelController
{
    protected $model = Category::class;
    // При выводе 1 модели обязательно проверяем её статус
    protected function beforeGet(Builder $c): Builder
    {
        $c->where('active', true);

        return $c;
    }

    // При выводе списка моделей делаем то же самое
    protected function beforeCount(Builder $c): Builder
    {
        // Пока условия одинаковые - можно не дублировать код
        return $this->beforeGet($c);
    }
}

src/Controllers/Web/Products.php

<?php

namespace App\Controllers\Web;

use App\Models\Product;
use Illuminate\Database\Eloquent\Builder;
use Vesp\Controllers\ModelController;

class Products extends ModelController
{
    protected $model = Product::class;

    protected function beforeGet(Builder $c): Builder
    {
        // Не только товар должен быть включен
        $c->where('active', true);
        // Но и его категория!
        $c->whereHas('category', static function (Builder $c) {
            $c->where('active', true);
        });

        return $c;
    }

    protected function beforeCount(Builder $c): Builder
    {
        return $this->beforeGet($c);
    }

    protected function afterCount(Builder $c): Builder
    {
        // Не забываем выбрать категорию товара, пригодится
        $c->with('category:id,title');

        return $c;
    }
}
Осталось только добавить новую группу маршрутов в core/routes.php:
$group->group(
    '/web',
    static function (RouteCollectorProxy $group) {
        $group->any('/categories[/{id}]', App\Controllers\Web\Categories::class);
        $group->any('/products[/{id}]', App\Controllers\Web\Products::class);
    }
);
Проверяем работу контроллера товаров, переходя по http://vesp-shop.test/api/web/products:
Обратите внимание, какие запросы у нас получаются в SQL. Появилась и проверка на статус родительской категории, и большой массив выбираемых категорий для подстановки.
Тем не менее, выборка 845 товаров проходит за ~9 миллисекунд.

Вывод товаров на сайте

В нашем API всё готово, осталось только вывести товары на сайте. Напоминаю, что в приложении site ничего не особенного не подключено. Там нет мультиязычности, компонентов админки из Vesp - один только BootstrapVue.
Так сделано для минимального размера публичного приложения - как вы помните, я предлагаю подключать нужное, а не отключать ненужное.
Единственное, что нам нужно подключить в site/nuxt.config.js - это Axios, для запросов на сервер:
Config.modules = ['bootstrap-vue/nuxt', '@nuxtjs/axios', '@nuxtjs/pwa']
Теперь создаём новый компонент для вывода списка товаров.

src/site/components/list-products.vue

Для начала пишем простейший шаблон, который будет в цикле проходить по массиву товаров и выводить их id, название и цену:
<template>
  <div class="products-list">
    <div v-for="product in products" :key="product.id" class="product">
      {{ product.id }}. {{ product.title }} &mdash; {{ product.price }} руб.
    </div>
  </div>
</template>
Дальше в этом же файле идёт javascript логика:
<script>
export default {
  // Название компонента используется для импорта
  name: 'ListProducts',
  // Список принимаемых параметров
  props: {
    // Параметр для работы v-model
    value: {
      type: [String, Number],
      default: 1,
    },
    // Количество выводимых товаров на странице
    limit: {
      type: [String, Number],
      default: 20,
    },
    // Сортировка
    sort: {
      type: String,
      default: 'id',
    },
    // Направление сортировки
    dir: {
      type: String,
      default: 'asc',
    },
  },
  // Внутренние данные компонента
  data() {
    return {
      // Количество загруженных товаров
      total: 0,
      // Пустой массив для товаров
      products: [],
    }
  },
  // Функция загрузки для компонентов NuxtJs
  async fetch() {
    try {
      // Определяем параметры запроса для контроллера
      const params = {limit: this.limit, page: this.page, sort: this.sort, dir: this.dir}
      // Делаем асинхронный запрос
      const {data} = await this.$axios.get('web/products', {params})
      // Сохраняем ответ
      this.products = data.rows
      this.total = data.total
      // Генерируем событие об успешной загрузке с передачей полученных данных
      // Это могут слушать родительские компоненты, чем мы позже и воспользуемся
      this.$emit('load', data)
    } catch (e) {
      // Если есть ошибка - выводим в консоле
      console.error(e)
    }
  },
  // Дальше идут вычисляемые значения
  computed: {
    // Текущая страница вывода товаров
    page: {
      get() {
        return this.value
      },
      set(newValue) {
        // Уведомление родительского компонента об изменении 
        this.$emit('input', newValue)
      },
    },
  },
  // Следим за некоторыми параметрами
  watch: {
    // Новый запрос на сервер при изменении страницы
    page() {
      this.$fetch()
    },
  },
}
</script>
Итак, что здесь происходит? У нас есть разные параметры по умолчанию, из который номер страницы является v-model компонента, то есть он реактивен.
При этом сам компонент следит за номером страницы, и если он меняется - запускает функцию fetch(), которая отправляет новый запрос в API, используя указанные параметры, включая и страницу. Таким образом мы должны будем управлять постраничной загрузкой товаров.
Приводим src/site/pages/index.vue к самому минимальному виду:
<template>
  <div>
    <!-- вывод подключенного компонента вообще без параметров -->
    <list-products />
  </div>
</template>

<script>
// Импорт нашего компонента
import ListProducts from '../components/list-products'

export default {
  // И подключение для работы
  components: {ListProducts},
}
</script>
Появился совершенно минимальный вывод товаров без какого-либо функционала:
Но нам ведь нужно как-то пролистать весь каталог, верно? Для этого потребуется постраничная навигация, так что мы используем соответствующий компонент из BootstrapVue.
Опять меняем nuxt.config.js и добавляем PaginationPlugin в конфигурацию BootstrapVue:
Config.bootstrapVue.componentPlugins = ['LayoutPlugin', /*...*/ 'PaginationPlugin']
Еще нужно добавить и стили в файле src/site/assets/scss/index.css:
@import '~bootstrap/scss/pagination';
Теперь можно использовать пагинацию в index.vue:
<template>
  <div class="mt-5">
    <!--добавляем новые параметры вместе с событием на загрузку данных -->
    <list-products v-model="page" :limit="limit" @load="onLoad" />
    <!-- пагинация использьзует те же параметры -->
    <b-pagination v-model="page" :total-rows="total" :per-page="limit" class="mt-3" />
  </div>
</template>

<script>
import ListProducts from '../components/list-products'

export default {
  components: {ListProducts},
  // Добавились параметры по умолчанию
  data() {
    return {
      page: 1,
      limit: 20,
      total: 0,
    }
  },
  methods: {
    // Это действие срабатывает при вызове $emit('load') в list-products,
    // и передаёт нам загруженное, а мы из него сохраняем количество результатов
    onLoad(data) {
      this.total = data.total
    },
  },
}
</script>
Что здесь происходит? Во-первых, мы добавили пагинацию, которая так же, как и наш компонент, принимает v-model номер страницы. Когда мы указываем одно и то же значение обоим компонентам, мы их связываем и при изменении page в пагинации, оно же поменяется и в list-products, что вызовет новый запрос на сервер.
А во-вторых, мы слушаем событие load в компоненте товаров и сохраняем общее количество результатов, чтобы передать их в b-pagination. Таким образом пагинация узнаёт сколько всего у нас всего результатов и может отрисовать нужное количество страниц.
Итого:
  • загружается первая страница в list-products
  • b-pagination получает количество результатов и отрисовывает кнопки
  • при нажатии на кнопки меняется v-model
  • происходит новая загрузка list-products
И вот результат (кликните на картинку, чтобы посмотреть GIFку):

Заключение

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

10 комментариев

Спасибо! Темп уроков не может не радовать)
Из замечаний, наверное все же вместо src/site/index.vue имелось ввиду src/site/pages/index.vue И код отличается в статье и гитхабе в файле list-products.vue - нету того же this.$emit('load', data) например. В файле pages/index.vue со статьей вроде различия только в
    onLoad({total}) {
      this.total = total
    },
Рабочий вариант (при котором работает пагинация)
    onLoad(data) {
      this.total = data.total
    },
На гитхабе как раз тот вариант, который я выше указал.
Василий Наумкин
onLoad(data) {
  this.total = data.total
},
и
onLoad({total}) {
  this.total = total
},
В нашем случае одно и то же.
Указание в функции {total} означает, что мы достаём только этот ключ из присланной переменной, потому что нам больше ничего не надо.
Это называется деструктурирующее присваивание. Код я пишу прямо вместе с заметками, поэтому могут быть небольшие расхождения.
Василий Наумкин
Да, всё верно, спасибо.
Заметку поправил.
Александр Наумов
Василий, добрый день!
Подскажи, пожалуйста, как подключаются стили Bootstrap-vue?
Вроде бы все очевидно и просто, но что-то не работает и нагуглить быстро не вышло.
На странице с компонентом BootstrapVue Pagination нигде не сказано про стили.
@import '~bootstrap/scss/pagination';
Сделал с подключением компонента BootstrapVue Sidebar по аналогии:
@import '~bootstrap/scss/sidebar;
Но в консоли получил ошибку:
[dev:site] SassError: Can't find stylesheet to import.
[dev:site]    ╷
[dev:site] 15 │ @import '~bootstrap/scss/sidebar';
Василий Наумкин
Самый простой способ решить все подобные проблемы - просто подключить вообще все стили
@import '~bootstrap/scss/bootstrap.scss';
@import '~bootstrap-vue/src/index.scss';
Но мне такое не нравится, потому что тогда будут собраны и ненужные стили, а это утяжелит страницу. Поэтому я что из родного Bootstap, что из Bootstrap-Vue, подключаю только нужное.
Компонента Sidebar у Bootstrap нет, значит нужны только стили из Bootstrap-Vue
@import '~bootstrap-vue/src/components/sidebar/index.scss';
Стили Bootstrap-Vue только дополняют таковые из оригинального Bootstrap, когда это нужно для функционала.
Александр Наумов
Компонента Sidebar у Bootstrap нет, значит нужны только стили из Bootstrap-Vue
@import '~bootstrap-vue/src/components/sidebar/index.scss';
Спасибо большое! Тупанул, понял куда смотреть было нужно.
Самый простой способ решить все подобные проблемы - просто подключить вообще все стили Но мне такое не нравится, потому что тогда будут собраны и ненужные стили, а это утяжелит страницу.
Такой подход одна из сильных сторон Vesp, если не будет порядка, то в последствии захлебнешься в мусоре.
Очень познавательно. Спасибо! Все получилось! Возник единственный вопрос, но он, подозреваю, связан с моим незнанием темы. При проверке работы контроллера товаров, я получаю следующую картину. По адресу http://my-site.ru/api/web/products я вижу страницу с JSON где выводятся все 830 товаров в 100 категорях, но на этой странице (http://my-site.ru/api/web/products) Clockwork ничего не отображает.
Тут надо оговориться, что сайт у меня висит на VPS - т.е. фактически уже в продакшене, и отображается на домене. И я его перегенерирую после каждого обновления. Clockwork же я могу видеть на странице админки. Т.е. не на странице /api/web/products как у тебя, а на /admin/products . И там у меня Clockwork отображает только 84 категории. Но наверное это все пока не существенно, во всяком случае до тех пор, пока я не научусь в этом предметно разбираться.
Василий Наумкин
Не знаю даже, что ответить. По идее работать должно везде, возможно просто глюк, который лечится перезагрузкой страницы.
У меня всё работает, проверил:
bezumkin.ru
Personal website of Vasily Naumkin
Прямой эфир
Александр Наумов
23.07.2024, 00:20:37
Василий, спасибо большое!!
Василий Наумкин
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
Василий, спасибо! Извини, тупанул.
Василий Наумкин
22.01.2024, 07:43:20
Давай-давай!
Василий Наумкин
24.12.2023, 14:26:13
Спасибо!