Оформление товаров и категорий

Сегодня мы оформим страницы товаров и категорий.
Думаю, на этом работа по выводу каталога будет окончена и можно будет переключиться на корзину и оформление заказов.
Как вы, наверное, помните, мы написали компонент для вывода всех товаров и теперь можем его легко расширить для вывода товаров конкретной категории.
Делается это добавлением нового параметра, и при его наличии, изменением адреса запроса на контроллер товаров категории, который мы написали в прошлом уроке.
Редактируем frontend/src/site/components/list-products.vue:
<script>
export default {
  name: 'ListProducts',
  props: {
    // Указываем наш новый параметр
    category: {
      type: String,
      default: null,
    },
    // ...
  },
  async fetch() {
    // ...
    // меняем адрес для запросов на computed переменную
    const {data} = await this.$axios.get(this.url, {params})
    // ...
  },
  computed: {
    // И назначаем новую переменную для запроса
    // Она зависит от указания alias категории
    url() {
      return this.category ? 'web/categories/' + this.category + '/products' : 'web/products'
    },
    // ...
  },
</script>
Теперь мы можем просто вызвать наш компонент на странице категории frontend/src/site/pages/_category/index.vue:
<template>
  <div class="pt-5">
    <!-- Напрямую указываем категорию из параметров маршрута -->
    <list-products :category="$route.params.category" />
  </div>
</template>

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

export default {
  components: {ListProducts},
  // Проверяем наличие категории в параметрах маршрута
  validate({params}) {
    return Boolean(params.category)
  },
}
</script>
И если теперь зайти на адрес /category-1, вы должны увидеть список товаров этой категории.
Но хотелось бы добавить те же функции, что и на главной странице: пагинацию, сортировку и смену внешнего вида. Только писать это всё заново совсем не хочется, да и вообще, дублировать функционал - всегда плохая идея.
Давайте вынесем верхнюю панель с кнопочками с главной страницы в отдельный компонент, чтобы его можно было использовать и на странице категории.

Компонент управления каталогом

Создаём frontend/src/site/components/list-products-actions.vue:
<template>
  <b-row class="mt-3 mb-3">
    <!-- Переносим всё содержимое этого тега с главной страницы 
        без изменений -->
  </b-row>
</template>

<script>
export default {
  // Имя компонента
  name: 'ListProductsActions',
  props: {
    // v-model у нас будет вариант оформления товаров
    // списком или плиткой
    value: {
      type: Boolean,
      required: true,
    },
    // Дополнительные параметры
    sort: {
      type: String,
      required: true,
    },
    dir: {
      type: String,
      required: true,
    },
  },
  computed: {
    // Наш v-model, который будет использоваться в template,
    // а изменения будет слушать родительская страница
    listView: {
      get() {
        return this.value
      },
      set(newValue) {
        this.$emit('input', newValue)
      },
    },
  },
  methods: {
    // Этот метод переезжает в иземенённом виде
    onSort(key) {
      // Так как sort и dir страли параметрами, нельзя их менять напрямую,
      // как и v-model, теперь мы будем менять их через события
      if (this.sort === key) {
        this.$emit('update:dir', this.dir === 'asc' ? 'desc' : 'asc')
      } else {
        this.$emit('update:sort', key)
        this.$emit('update:dir', 'asc')
      }
    },
  },
}
У компонентов Vue 2 может быть только один v-model, и в него мы поместили переключение внешнего вида оформления. Но параметры сортировки тоже будут обновляться внутри компонента, и за ними нужно можно следить через модификатор .sync.
Сейчас покажу, как именно. Редактируем frontend/src/site/pages/index.vue:
<template>
  <div>
    <!-- Наш новый компонент с кнопками, обратите внимание 
        на вызов параметров с модификаторами .sync="..." -
        это и есть слежка за их изменениями -->
    <list-products-actions v-model="listView" :sort.sync="sort" :dir.sync="dir" />

    <!-- Тут всё без изменений -->
    <list-products v-model="page" :limit="limit" :sort="sort" :dir="dir" :list-view="listView" @load="onLoad" />

    <b-pagination v-model="page" :total-rows="total" :per-page="limit" class="mt-5" />
  </div>
</template>

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

export default {
  name: 'IndexPage',
  // Регистрация нового компонента
  components: {ListProductsActions, ListProducts},
  data() {
    // ...
  },
  computed: {
       // ...
  },
  methods: {
    onLoad({total}) {
      this.total = total
    },
    // Метод onSort удаляем
  },
}
</script>
Еще раз насчёт слежения за передаваемыми параметрами: общий смысл в том, что если параметр может быть изменён внутри компонента, он обновляется через событие снаружи.
Обычно меняется только 1 параметр и тогда достаточно использовать v-model, но в случае с нашим новым компонентом таких параметра сразу 3. Поэтому мы используем один v-model, как обычно, а 2 других параметра слушаем через модификатор .sync.
Насколько я понимаю, модификатор .sync появился только в Vue 2.3+, а до этого был только v-model, и теперь он стал не особо нужным. Но лично я предпочитаю его использовать для выразительности компонента, чтобы сразу было понятно где передаётся изменяющийся параметр. Случаев с несколькими меняющимися параметрами в моей практике не так уж и много.
Теперь нам нужно вызвать все 3 компонента на странице категории frontend/src/site/pages/_category/index.vue точно так же, как и на главной странице, с одним единственным отличием - у <list-products /> должен быть указан параметр :category="...". Думаю, вы прекрасно с этим справитесь самостоятельно, так что я не буду копировать код страницы сюда.
В принципе, можно было бы даже просто расширить главную страницу и поменять только тег template, но мне кажется, что на главной потом будет что-то ещё, помимо вывода каталога.

Оформление товара

На прошлом занятии мы написали выборку основных данных товара, давайте теперь добавим в них еще и галерею с категорией.
Редактируем контроллер core/src/Controllers/Web/Category/Products.php:
// Меняем запрос перед получением 1 модели
protected function beforeGet(Builder $c): Builder
{
    $c->where('active', true);
    // Выбираем категорию
    $c->with('category:id,alias,title');
    // И активные файлы, в порядке сортировки
    $c->with('productFiles', static function (HasMany $c) {
        $c->where('active', true);
        $c->orderBy('rank');
        $c->select('product_id', 'file_id');
        $c->with('file:id,updated_at');
    });

    return $c;
}
В результате на странице появляются все нужные данные, остаётся их только оформить.
Редактируем frontend/src/site/pages/_category/_product/index.vue:
<template>
  <div class="mt-5">
    <b-link :to="{name: 'index'}">&larr; Вернуться назад</b-link>

    <b-row class="mt-3">
      <b-col v-if="product.product_files.length" md="6" class="mt-3 mt-md-0 order-2 order-md-1">
        <!-- Карусель BootstrapVue-->
        <b-carousel controls indicators>
          <b-carousel-slide v-for="image in product.product_files" :key="image.file_id">
            <!-- В карусели указываем собственное оформление картинок -->
            <template #img>
              <!-- Не забываем про экраны высокой плотности, указываем srcset -->
              <b-img
                :src="$image(image.file, {w: thumbWidth, h: thumbHeight, fit: 'crop'})"
                :srcset="$image(image.file, {w: thumbWidth * 2, h: thumbHeight * 2, fit: 'crop'}) + ' 2x'"
                :width="thumbWidth"
                :height="thumbHeight"
                alt=""
              />
            </template>
          </b-carousel-slide>
        </b-carousel>
      </b-col>
      <b-col md="6" class="order-1 order-md-2">
        <!-- Тут всё стандартно-->
        <h1>{{ product.title }}</h1>
        <div class="mt-3">{{ product.description }}</div>
        <div class="font-weight-bold mt-3">{{ product.price }} руб.</div>
        <div class="mt-3">
          <!-- Кнопка пока ничего не делает -->
          <b-button variant="primary " disabled>В корзину!</b-button>
        </div>
      </b-col>
    </b-row>
  </div>
</template>

<script>
export default {
    //...
  data() {
    return {
      // Прописываем размеры для картинок
      // В будущем их можно сделать динамическими и 
      // рассчитывать в зависимости от размера экрана
      thumbWidth: 540,
      thumbHeight: 360,
      product: {
        // Прописываем ключи, чтобы phpStorm их видел и подсказывал
        category: {},
        product_files: [],
      },
    }
  },
}
</script>
Для вывода картинок мы используем карусель из BootstrapVue, которую нужно подключить в компонентах frontend/src/site/nuxt.config.js
Config.bootstrapVue.componentPlugins = [
  // ...
  'CarouselPlugin',
]
И добавить оформление в ``
/* Подключение всех стилей я зыбыл на одном из прошлых занятий -
    его нужно закомментировать */
/* @import '~bootstrap/scss/bootstrap'; */

/* Подключаем стили отображения карусели */
@import '~bootstrap/scss/carousel';
@import '~bootstrap/scss/utilities/screenreaders';

/* И стили нужные для отображения загрузки каталога */
@import '~bootstrap/scss/spinners';
@import '~bootstrap/scss/utilities/position';
@import '~bootstrap/scss/utilities/background';
Вот, что у меня получилось в итоге.

Хлебные крошки

Ссылка "вернуться назад" выглядит не очень, давайте вместо неё напишем свой компонент хлебных крошек, который будем вызывать на страницах категории и товара.
Создаём файл frontend/src/site/components/breadcrumbs.vue:
<template>
  <b-breadcrumb>
    <!-- Иконку на главную страницу выводим всегда -->
    <b-breadcrumb-item :to="{name: 'index'}">
      <fa icon="home" />
    </b-breadcrumb-item>

    <!-- Ссылка на категорию тоже, но активна она только
        при наличии товара -->
    <b-breadcrumb-item :to="{name: 'category', params: {category: category.alias}}" :active="!product">
      {{ category.title }}
    </b-breadcrumb-item>

    <!-- А вот ссылку на товар выводим только, если его передали -->
    <b-breadcrumb-item
      v-if="product"
      :to="{name: 'category-product', params: {category: category.alias, product: product.alias}}"
      active
    >
      {{ product.title }}
    </b-breadcrumb-item>
  </b-breadcrumb>
</template>

<script>
export default {
  name: 'Breadcrumbs',
  // Параметров всего 2
  props: {
    // Обязательная категория
    category: {
      type: Object,
      required: true,
    },
    // И опциональный товар
    product: {
      type: Object,
      default: null,
    },
  },
}
</script>
Как видите, я использую еще один компонент из BootstrapVue, который нам нужно подключить в frontend/src/site/nuxt.config.js:
// Заодно подключаем работу с FontAwesome
Config.buildModules = [/*...*/, '@nuxtjs/fontawesome']
 // Основные настройки
Config.fontawesome = {
  addCss: false,
  component: 'fa',
  icons: {
    // И загрузка ровно одной иконки, которая используется в крошках
    solid: ['faHome'],
  },
}

// А вот и компонент хлебных крошек
Config.bootstrapVue.componentPlugins = [
  // ...
  'BreadcrumbPlugin',
]
И, конечно, добавляем нужные стили в frontend/src/site/assets/scss/index.scss из установленных библиотек в node_modules:
@import '~bootstrap/scss/breadcrumb';
@import '@fortawesome/fontawesome-svg-core/styles.css';
Вызываем крошки на странице товара frontend/src/site/pages/_category/_product/index.vue как обычно - импортируем и регистрируем в script, а затем указываем в template:
<template>
  <div class="mt-5">
    <breadcrumbs :category="product.category" :product="product" />
    <!-- ... -->
  </div>
</template>
Всё сразу заработало:
А вот на странице категории нам еще нужно добавить загрузку данных с сервера. Потому что на данный момент мы ничего, кроме переданного на страницу alias не знаем, а нам нужен ещё и title. Так в файле frontend/src/site/pages/_category/index.vue добавляем еще asyncData():
<template>
  <div class="pt-5">
    <breadcrumbs :category="category" />
    <!-- ... -->
  </div>
</template>

<script>
export default {
  async asyncData({app, params, error}) {
    try {
      const {data} = await app.$axios.get('web/categories/' + params.category)
      return {category: data}
    } catch (e) {
      error({statusCode: e.response.status, message: e.message})
    }
  },
  data() {
    return {
      // ...
      // Место для хранения загруженных данных
      category: {},
    }
  },
}
</script>
Теперь и тут всё в порядке:
На главной странице крошки не выводим.

Заключение

Теперь у нас работает нафигация по каталогу. Вы можете перейти на страницу товара с главной, а затем выйти в его категорию и обратно на главную.
Единственное, что все эти переходы выглядят как-то резко, без анимации. Поэтому давайте добавим стили и для переходов, как в админке сделано по умолчанию.
Прописываем новые стили в frontend/src/site/assets/scss/index.scss:
.page-enter-active,
.page-leave-active,
.layout-enter-active,
.layout-leave-active {
  transition: opacity 0.1s
}

.page-enter,
.page-leave-active,
.layout-enter,
.layout-leave-active {
  opacity: 0
}
Подробнее про возможности анимирования переходов между страницами можно почитать в документации NuxtJs.
И теперь всё красиво (кликаем на GIFку):
Все изменения, как обычно, одним коммитом на Github.

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

Александр Наумов
Василий, добрый день!
Решил к кнопкам "Добавить в корзину" добавить Bootstrap Toast, вспомнил, что у нас уже установлен для этих целей другой @nuxtjs/toast.
Вопрос: почему выбран @nuxtjs/toast, а не Bootstrap Toas, чем он лучше?
Василий Наумкин
У меня не получилось нормально вызывать бутстраповский в перехватчике Axios. Это давно было, сейчас возможно уже поправили.
А так эти компоненты все плюс-минус одинаковые.
Александр Наумов
Ясно, спасибо!
bezumkin.ru
Личный сайт Василия Наумкина
Прямой эфир
Александр Наумов
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
Спасибо!