Корзина товаров

Каталог товаров вывели и оформили, теперь нужно сделать корзину, куда будем их складывать.
Авторизации на сайте у нас нет, значит корзина будет храниться не в базе данных, а на клиенте. Для долговременного хранения мы используем localStorage, а для текущего состояния - Vuex.
Vuex - это глобальное хранилище нашего приложения, к которому может обращаться любой компонент Vue. Он позволяет им обмениваться данными, даже если они находятся на разных страницах и ничего друг о друге не знают.
Можно сказать, что это такая "внутренняя сессия" приложения, только очень функциональная. Вот сегодня, на примере работы с корзиной, мы её и освоим.

Шапка и подвал

Первым делом нам нужно добавить место для кнопки корзины, так что давайте выведем какую-нибудь навигационную панель.
Создаём frontend/src/site/components/app/navbar.vue:
<template>
  <b-navbar variant="light" toggleable="md">
    <b-container>
      <b-navbar-brand :to="{name: 'index'}">
        <!-- Логотип я скопировал из картинок админки -->
        <b-img src="~/assets/images/logo-vesp.svg" width="152" height="20" alt="Vesp" />
      </b-navbar-brand>
      <!-- Кнопку корзины пока отключаем -->
      <b-button variant="primary" disabled class="ml-auto">
        <fa icon="cart-shopping" />
      </b-button>
    </b-container>
  </b-navbar>
</template>

<script>
export default {
  name: 'AppNavbar',
}
</script>
И подключаем плагин Navbar из BootstrapVue в нашем frontend/src/site/nuxt.config.js:
Config.bootstrapVue.componentPlugins = [
  // ...
  'NavbarPlugin',
]

// Не забываем и про иконку корзины
Config.fontawesome = {
  // ...
  icons: {
    solid: ['faHome', 'faCartShopping'],
  },
}
Теперь можно вставить новый компонент в основной layout сайта, открываем frontend/src/site/layouts/default.vue:
<template>
  <div class="min-vh-100 d-flex flex-column">
    <app-navbar />
    <b-container class="pt-4 flex-grow-1">
      <nuxt />
    </b-container>
  </div>
</template>
<script>
import AppNavbar from '../components/app/navbar'

export default {
  components: {AppNavbar},
}
</script>
Я еще пробежался по другим страницам и компонентам, чтобы убрать у них отступ сверху. Теперь отступ от app-navbar есть только в одном месте - в основном шаблоне.
Ну и раз уж такое дело, давайте заодно и компонент подвала нарисуем. Создаём frontend/src/site/components/app/footer.vue:
<template>
  <footer class="bg-light mt-5 py-3">
    <b-container class="text-muted small">
      <b-row>
        <b-col md="6" class="font-weight-bold text-center text-md-left">
            Vesp Shop
        </b-col>
        <b-col md="6" class="text-center text-md-right mt-2 mt-md-0">
            <!-- Здесь можно указать и другое имя =) -->
            bezumkin &copy; 2020 &mdash; {{ year }}
        </b-col>
      </b-row>
    </b-container>
  </footer>
</template>

<script>
export default {
  name: 'AppFooter',
  data() {
    return {
      year: new Date().getFullYear(),
    }
  },
}
</script>
Думаю, добавить его в шаблон вы сможете и без моих подсказок.
Уже похоже на настоящий сайт, правда? Теперь хорошо заметно, что при переходе по страницам шапка и подвал не перезагружаются, меняется только содержимое в центре.

Работа с корзиной

Для того, чтобы включить Vuex в приложении Nuxt, нужно создать файл index.js в корневой директории store.
Редактируем наше хранилище frontend/src/site/store/index.js:
// Это, собственно, и есть хранилище состояния приложения
export const state = () => ({
  // Корзина представляет из себя массив и по умолчанию пуста 
  cart: [],
})

// Мутации - это статические функции, которые меняют состояние хранилища
export const mutations = {
  // Добавление товара в корзину получает первым параметром состояние,
  // а вторым - товар для добавления
  addToCart(state, product) {
    // Дальше мы просто закидываем id, title и price товара в массив корзины
    state.cart.push({id: product.id, title: product.title, price: product.price})
  },
  // Удаление товара из корзины принимает те же параметры
  removeFromCart(state, product) {
    // Для удаления мы ищем первый товар с указанным id
    const idx = state.cart.findIndex((i) => i.id === product.id)
    // И если нашли
    if (idx > -1) {
      // То удаляем его из корзины
      state.cart.splice(idx, 1)
    }
  },
  // Функция очистки корзины просто обнуляет массив
  clearCart(state) {
    state.cart = []
  },
}

// А это вычисляемые свойства хранилища, 
// примерно как computed переменные в компонентах
export const getters = {
  // Эта функция вернёт количество товаров в корзине
  cartProducts(state) {
    return state.cart.length
  },
  // А эта подсчитает их сумму
  cartTotal(state) {
    let total = 0
    state.cart.forEach((i) => {
      total += i.price
    })
    return total ? total.toFixed(2) : 0
  },
}
Вся логика работы корзины в одном файле, теперь мы можем использовать наше новое хранилище в компонентах.
Редактируем страницу товара frontend/src/site/pages/_category/_product/index.vue и включаем кнопку добавления в корзину.
<template>
    <div>
        <!-- ... -->
        <!-- При клике происходит запуск мутации addToCart с передачей товара -->
        <b-button variant="primary" @click="$store.commit('addToCart', product)">
            В корзину!
        </b-button>
    </div>
</template>
Вот так просто теперь можно где угодно выводить кнопки с добавлением товара, хоать на его странице, хоать в общем списке. Главное - передать мутации объект с id, title и price товара.
Теперь меняем нашу кнопку корзины в шапке frontend/src/site/components/app/navbar.vue:
<template>
  <b-navbar ...>
      <!-- ... -->
      <!-- Включаем кнопку только если в корзине есть товары -->
      <b-button variant="primary" :disabled="!products" class="ml-auto" @click="showCart = true">
        <fa icon="cart-shopping" />
        <!-- Выводим сумму корзины, если есть -->
        <template v-if="total">{{ total }} руб.</template>
      </b-button>
  </b-navbar>
</template>

<script>
export default {
  name: 'AppNavbar',
  // Добавляем вычисляемые переменные
  computed: {
    // Которые просто вызывают геттеры из хранилища
    total() {
      // Сумма корзины
      return this.$store.getters.cartTotal
    },
    products() {
      // И количество товаров
      return this.$store.getters.cartProducts
    },
  },
}
</script>
Как видите, мы просто вызываем заранее прописанные методы из хранилища в любом нужном нам месте приложения. Теперь при добавлении товара в корзину кнопка активируется и покажет итоговую сумму корзины.
Кликаем на GIFку:
Кстати говоря, состояние нашего хранилища во время разработки всегда можно посмотреть а браузерном расширении Vue DevTools:
Сразу видно и содержимое корзины, и значения геттеров.

Вывод корзины

Теперь нужно написать компонент самой корзины, который будет открываться при клике на кнопку в шапке.
Создаём frontend/src/site/components/cart.vue:
<template>
  <!-- Если в корзине есть товары-->
  <div v-if="amount" class="pb-3">
    <!-- Перебираем "плоский" массив, который у нас генерируется в свойстве products -->
    <b-row v-for="product in products" :key="product.id" align-v="center" class="mt-3">
      <b-col md="4" class="text-center text-md-left mb-2 mb-md-0">
        <div class="font-weight-bold">{{ product.title }}</div>
        <div class="small text-muted">{{ product.price }} руб.</div>
      </b-col>
      <!-- Колонка с количеством товара и кнопками его изменения-->
      <b-col cols="6" md="4" class="d-inline-flex align-items-center justify-content-center">
        <!-- Эта кнопка уменьшает количество -->
        <b-button variant="light" size="sm" @click="$store.commit('removeFromCart', product)">
          <fa icon="minus" />
        </b-button>
        <div class="px-2">{{ product.amount }} шт.</div>
        <!-- А эта увеличивает -->
        <b-button variant="light" size="sm" @click="$store.commit('addToCart', product)">
          <fa icon="plus" />
        </b-button>
      </b-col>
      <!-- Итоговую сумму товара можно посчитать прямо в шаблоне -->
      <b-col cols="6" md="4" class="text-right">
        <strong>{{ Number(product.price * product.amount).toFixed(2) }}</strong> руб.
      </b-col>
    </b-row>

    <!-- Если строк в корзине больше одной - выводим общее итого -->
    <div v-if="products.length > 1" class="bg-light mt-3 py-3 border-top text-center">
      Итого <strong>{{ amount }}</strong> шт., на сумму <strong>{{ total }}</strong> руб.
    </div>

    <!-- Ну и кнопка очистки корзины вызывает соответствующий метод хранилища -->
    <div class="mt-4">
      <b-button variant="danger" @click="$store.commit('clearCart')">Очистить корзину</b-button>
    </div>
  </div>
  // И сообщение о пустой корзине, если товаров в ней нет
  <div v-else>
    <div class="p-4 text-center font-weight-bold">Ваша корзина пуста</div>
  </div>
</template>

<script>
export default {
  name: 'Cart',
  computed: {
    // Приводим корзину к "плоскому" виду для вывода в списке
    // То есть, объединяем товары по id, и добавляем их общее количество
    // Так как это вычисляемое свойство - оно будет пересчитываться автоматически
    // при изменении корзины в хранилище
    products() {
      const products = {}
      this.$store.state.cart.forEach((i) => {
        if (!products[i.id]) {
          products[i.id] = {...i, amount: 1}
        } else {
          products[i.id].amount += 1
        }
      })
      return Object.values(products)
    },
    // Тут как обычно, просто ссылки на методы в хранилище
    total() {
      return this.$store.getters.cartTotal
    },
    amount() {
      return this.$store.getters.cartProducts
    },
  },
}
</script>
Благодаря тому, что всё у нас везде такое себе реактивное, и хранится в одном хранилище, все части приложения на него завязанные, будут обновляться самостоятельно.
Осталось только вывести корзину в модальном окошке. Редактируем frontend/src/site/components/app/navbar.vue:
<template>
  <b-navbar variant="light" toggleable="md">
    <!-- -->
    <b-modal v-model="showCart" title="Корзина" hide-footer>
      <cart />
    </b-modal>
  </b-navbar>
</template>

<script>
import Cart from '../../components/cart'

export default {
  name: 'AppNavbar',
  components: {Cart},
  data() {
    return {
      showCart: false,
    }
  },
  // ...
}
</script>
И не забываем импортировать Modal из BoostrapVue вместе с иконками для кнопок корзины в frontend/src/site/nuxt.config.js:
Config.bootstrapVue.componentPlugins = [
  // ...
  'ModalPlugin',
]

Config.fontawesome = {
  // ...
  icons: {
    solid: [/* ... */, 'faMinus', 'faPlus'],
  },
}
А если и забудем - в консоли браузера будет про это ошибка.
Закидываем и нужные стили для всего используемого в frontend/src/site/assets/scss/index.scss:
@import '~bootstrap/scss/modal';
@import '~bootstrap/scss/close';
@import '~bootstrap/scss/transitions';
@import '~bootstrap/scss/utilities/borders';
@import '~bootstrap-vue/src/components/modal';
Теперь всё должно работать вот так - кликаем на GIFку: Видно, что на фоне кнопка корзины меняет цену и отключается, когда в ней нет товаров.
Вот для этого и нужен Vuex - чтобы все компоненты обновлялись из одного единого хранилища.

Хранение корзины

Думаю вы заметили, что при обновлении страницы содержимое корзины куда-то исчезает? Дело в том, что Vuex существует только пока работает приложение в браузере. При обновлении страницы приложение создаётся заново, с пустым хранилищем.
Поэтому нам нужно дописать сохранение корзины в localStorage пользователя с последующей его загрузкой при запуске приложения.
Добавляем 2 новых действия в конце нашего хранилища frontend/src/site/store/index.js:
export const mutations = {
  // Прописываем сохранение корзины при каждом её изменении
  addToCart(state, product) {
    // ...
    this.dispatch('saveCart')
  },
  removeFromCart(state, product) {
    // ...
    this.dispatch('saveCart')
  },
  clearCart(state) {
    // ...
    this.dispatch('saveCart')
  },
}

export const actions = {
  // Сохранение корзины
  saveCart({state}) {
    // Хранить можно только строки, поэтому кодируем в JSON
    localStorage.setItem('cart', JSON.stringify(state.cart))
  },
  // Загрузка корзины
  loadCart({state, commit}) {
    // Мы не можем менять state напрямую, поэтому
    const products = JSON.parse(localStorage.getItem('cart')) || []
    // вызываем мутаторы в цикле, как если бы товары добавили вручную
    products.forEach((product) => {
      commit('addToCart', product)
    })
  },
}
Сохранение есть и работает при каждом изменении корзины, теперь нужно где-то вставить её начальную загрузку. Я предалагаю сделать это прямо в основном шаблоне frontend/src/site/layouts/default.vue:
export default {
  // ...
  mounted() {
    this.$store.dispatch('loadCart')
  },
}
И вот результат (кликаем на GIFку):
Таким же образом из actions хранилища можно делать и запросы в API, чтобы хранить корзину авторизованного пользователя в базе данных.

Заключение

Корзину можно еще усложнить, например при выводе товаров добавить запросы в API для проверки актуальности их цены и загрузки первой фотографии, но для наших задач это пока перебор.
Теперь вы видите, что работать с Vuex не только не сложно, но даже легко и приятно. Это отличный инструмент который позволяет удобно связать разрозненные компоненты приложения. На всякий случай вот еще ссылка на документацию по Vuex.
Вишенкой на тортике добавим новую кнопку прямо в список товаров, в нашем компоненте frontend/src/site/components/list-products.vue:
<template>
  <b-overlay :show="loading" opacity="0.5">
    <!-- -->
    <div class="d-flex justify-content-between align-items-center mt-2">
      <b-link :to="getLink(product)" class="font-weight-bold">{{ product.title }}</b-link>

      <!-- Просто кнопка, вызывающая действие в хранилище -->
      <b-button variant="light" size="sm" @click="$store.commit('addToCart', product)">
        <fa icon="cart-shopping" class="mr-1" />
        {{ product.price }} руб.
      </b-button>
  </div>
  <!-- -->
  </b-overlay>
</template>
И теперь товары закидываются в корзину прямо из списка, кликаем на GIFку:
Вот так по кирпичикам и собирается наше веб-приложение. На следующем занятии будем оформлять заказы.
Все изменения урока одним коммитом на Github.

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

Александр Наумов
Класс!!
Для тех, кто следует инструкциям из урока, нужно добавить @click="showCart = true" в:
<!-- Включаем кнопку только если в корзине есть товары -->
      <b-button variant="primary" :disabled="!products" class="ml-auto">
что бы было:
  <!-- Включаем кнопку только если в корзине есть товары -->
      <b-button variant="primary" :disabled="!products" class="ml-auto" @click="showCart = true">
Василий Наумкин
Класс!!
Рад, что тебе нравится!
нужно добавить @click="showCart = true" в:
Спасибо, поправил текст!
Александр Наумов
Василий, добрый день!
Хочу к кнопкам добавить toast. В конфиге:
Config.Vesp = {
...
  toast: true,
...
}
В файле: frontend/src/site/store/index.js
  addToCart(state, product) {
    state.cart.push({id: product.id, title: product.title, price: product.price})
    this.dispatch('saveCart')
    this.$toast.success('Товар добавлен в корзину')
  },
Выдает ошибку: Cannot read properties of undefined (reading 'success')
Не пойму, вроде все сделал правильно, подскажи, пожалуйста, что не так?
Василий Наумкин
В store нет никакого this.$toast, это отдельное такое место, в котором всё this - это сам store.
Он не должен вызывать ничего снаружи, это его нужно вызывать из своего компонента:
try {
    await this.$store.dispatch('addToCart', product)
    this.$toast.success('Товар добавлен в корзину')
} catch (e) {
    this.$toast.error('Не могу добавить товар')
    console.error(e)
}
Александр Наумов
Василий, добрый день!
У меня в: frontend/src/site/store/index.js
  async addToCart(state, product) {
    state.cart.push({id: product.id, title: product.title, price: product.price})
    try {
      await this.$store.dispatch('addToCart', product)
      this.dispatch('saveCart')
      this.$toast.success('Товар добавлен в корзину')
    } catch (e) {
      this.$toast.error('Не могу добавить товар')
      console.error(e)
    }
  },
А в frontend/src/site/nuxt.config.js
Config.Vesp = {
  components: true, 
  scss: false, 
  i18n: false, 
  toast: true, 
  axios: false, 
  utils: true, 
  filters: false, 
}
И выводит ошибку: Cannot read properties of undefined (reading 'error') ((
Василий Наумкин
В предыдущем сообщении я был не прав, внутри store есть и this.$toast, и многое другое - проверил на этом сайте. Но вот this.$store там у меня нет.
У тебя, судя по коду, в этом месте и возникает ошибка, которая прилетает в catch, где идёт попытка вывести уведомление. Я тебе писал код для пример вызова в компоненте (на странице), а не в самом store.
Вызови в этом методе console.log(Object.keys(this)) и посмотри в консоли, к чему именно там можно обращаться.
Еще проверь работу $toast на обычных страницах, прежде чем запихивать его в store, чего я не рекомендую вообще делать.
Александр Наумов
Спасибо!
Проблема была в подключении '@nuxtjs/toast', он не подтягивался с @vesp, то есть такая конструкция не работает:
Config.Vesp = {
...
  toast: true,
...
}
Пришлось установить @nuxtjs/toast во frontend.
Василий, ты рекомендуешь:
прежде чем запихивать его в store, чего я не рекомендую вообще делать.
Но ведь на кнопках "Добавить в корзину" по клику вызывается метод addToCart, который находится в store.
Получается на событие клик "Добавить в корзину" нужно вешать два метода (addToCart и метод для toast)?
Запустил в store console.log(Object.keys(this)) Получил:
[
    "_committing",
    "_actions",
    "_actionSubscribers",
    "_mutations",
    "_wrappedGetters",
    "_modules",
    "_modulesNamespaceMap",
    "_subscribers",
    "_watcherVM",
    "_makeLocalGettersCache",
    "dispatch",
    "commit",
    "strict",
    "getters",
    "_vm",
    "_devtoolHook",
    "replaceState",
    "registerModule",
    "unregisterModule",
    "$router",
    "app",
    "$config",
    "$toast",
    "$md",
    "$icon",
    "$axios",
    "$glightbox",
    "$hasScope",
    "$image"
]
Василий Наумкин
Проблема была в подключении '@nuxtjs/toast', он не подтягивался с @vesp
Странно, в зависимостях указан - https://github.com/bezumkin/vesp-frontend/blob/master/package.json
Получается на событие клик "Добавить в корзину" нужно вешать два метода (addToCart и метод для toast)?
Нужно вешать один локальный метод в этом же компоненте. А он тебе вызовет всё, что надо - и обработает как надо.
<template>
    <!--...-->
    <button @click="addToCart(product)">Добавить товар в корзину</button>
    <!--...-->
</template>
<script>
export default {
    // ...
    methods: {
        async addToCart(product) {
            try {
                await this.$store.dispatch('addToCart', product)
                this.$toast.success('Товар добавлен в корзину')
            } catch (e) {
                this.$toast.error('Не могу добавить товар')
                console.error(e)
            }
        },
    }
}
</script>
Запустил в store console.log(Object.keys(this))
Теперь знаешь, как проверять наличие чего-то в объектах
Александр Наумов
Василий, спасибо большое!
Только вот, на клик нужно вешать два метода, ведь по клику еще нужно сделать запись в store.
Должно быть так:
<template>
    <!--...-->
    <button @click="$store.commit('addToCart', product), addToCart(product)">Добавить товар в корзину</button>
    <!--...-->
</template>
Кнопка "Добавить товар в корзину" находится в двух местах: frontend/src/site/components/list-products.vue и здесь frontend/src/site/pages/_category/_product/index.vue Значит нужно скрипт куда-то выносить.
Мне казался хороший вариант отлавливать изменение суммы в кнопке корзины из шапки, но текущая сумма берется из store, от сюда мне и показалось самый простой вариант прописать одну строку в store/index.js.
Василий, может быть ты делал добавление иконки в toast?
В инструкции vue-toasted, говорится, что можно подключать иконки Fontawesome. А как я понял @nuxtjs/toast сделан на основе vue-toasted.
    this.$toast.success('Товар добавлен в корзину', {
      icon: 'cart-shopping',
    })
Василий Наумкин
Должно быть так:
Ты ерунду пишешь, так нельзя делать. Нужно указать свой локальный метод и прописать его в methods компонента - что я тебе и написал в своём примере.
Видимо тебя смущают одинаковые названия. Давай так:
<template>
    <!--...-->
    <button @click="ДобавитьВКорзинуСоСтраницыТовара(product)">Добавить товар в корзину</button>
    <!--...-->
</template>
Чуть ниже
<script>
export default {
    // ...
    methods: {
        async ДобавитьВКорзинуСоСтраницыТовара(product) {
            try {
                await this.$store.dispatch('ДобавитьТоварВЦентральноеХранилище', product)
                this.$toast.success('Товар добавлен в корзину')
            } catch (e) {
                this.$toast.error('Не могу добавить товар')
                console.error(e)
            }
        },
    }
}
</script>
Так понятно?
Почитай уже документацию Vuex, где написано, для чего он используется:
Vuex — паттерн управления состоянием + библиотека для приложений на Vue.js. Он служит централизованным хранилищем данных для всех компонентов приложения с правилами, гарантирующими, что состояние может быть изменено только предсказуемым образом.
Понимаешь? Он не должен вызывать показ всплывающих сообщений, это, можно сказать, локальная база данных приложения.
Василий, может быть ты делал добавление иконки в toast?
Нет, не делал
Александр Наумов
Спасибо! Да, мне здесь подучиться нужно.
bezumkin.ru
Личный сайт Василия Наумкина
Прямой эфир
Дмитрий
21.12.2024, 13:27:06
Здравствуйте.В ModX есть полезная функция "заморозить url родителя". При ее включении вместо: УРЛ п...
Дмитрий
14.12.2024, 09:10:38
Василий, прошу прощения, тупанул, не разобрался сразу. Фреймворк отличный! "Чистый лист" на vue, рис...
Василий Наумкин
05.12.2024, 20:01:14
В итоге основная ошибка была в неправильном общем root в Nginx, из-за чего запросы не улетали на фай...
Василий Наумкин
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
Василий! Как всегда очень круто! Моё почтение!
Уровни подписки
Спасибо!
500 ₽ в месяц
Эта подписка ничего не даёт, просто возможность сказать спасибо за мои заметки. Подписчики отмечаются зелёненьким цветом в комментариях.
Большое спасибо!
1 000 ₽ в месяц
И эта подписка не даёт ничего, кроме оранжевого цвета в комментариях и возможности сказать спасибо, но уже большое!