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

Ну что, модели созданы, контроллеры отлажены, пора бы и вывести товары на нашем сайте.

Для удобной работы предлагаю использовать очень известную библиотеку 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.

На следующем уроке добавим товарам картинки.

← Предыдущая заметка
Отладка SQL запросов контроллеров
Следующая заметка →
Галерея товаров
Комментарии (10)
bezumkinВасилий Наумкин
21.06.2022 04:58
onLoad(data) {
  this.total = data.total
},

и

onLoad({total}) {
  this.total = total
},

В нашем случае одно и то же.

Указание в функции {total} означает, что мы достаём только этот ключ из присланной переменной, потому что нам больше ничего не надо.

Это называется деструктурирующее присваивание. Код я пишу прямо вместе с заметками, поэтому могут быть небольшие расхождения.

bezumkinВасилий Наумкин
21.06.2022 04:58

Да, всё верно, спасибо.

Заметку поправил.

bezumkinВасилий Наумкин
19.09.2022 08:34

Самый простой способ решить все подобные проблемы - просто подключить вообще все стили

@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, когда это нужно для функционала.

bezumkinВасилий Наумкин
09.05.2023 01:01

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

У меня всё работает, проверил:

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
Спасибо!