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

Каталог товаров вывели и оформили, теперь нужно сделать корзину, куда будем их складывать.

Авторизации на сайте у нас нет, значит корзина будет храниться не в базе данных, а на клиенте. Для долговременного хранения мы используем 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)
bezumkinВасилий Наумкин
11.08.2022 15:46

Класс!!

Рад, что тебе нравится!

нужно добавить @click="showCart = true" в:

Спасибо, поправил текст!

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

В store нет никакого this.$toast, это отдельное такое место, в котором всё this - это сам store.

Он не должен вызывать ничего снаружи, это его нужно вызывать из своего компонента:

try {
    await this.$store.dispatch('addToCart', product)
    this.$toast.success('Товар добавлен в корзину')
} catch (e) {
    this.$toast.error('Не могу добавить товар')
    console.error(e)
}
bezumkinВасилий Наумкин
20.02.2023 11:28

В предыдущем сообщении я был не прав, внутри store есть и this.$toast, и многое другое - проверил на этом сайте. Но вот this.$store там у меня нет.

У тебя, судя по коду, в этом месте и возникает ошибка, которая прилетает в catch, где идёт попытка вывести уведомление. Я тебе писал код для пример вызова в компоненте (на странице), а не в самом store.

Вызови в этом методе console.log(Object.keys(this)) и посмотри в консоли, к чему именно там можно обращаться.

Еще проверь работу $toast на обычных страницах, прежде чем запихивать его в store, чего я не рекомендую вообще делать.

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

Проблема была в подключении '@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))

Теперь знаешь, как проверять наличие чего-то в объектах

bezumkinВасилий Наумкин
22.02.2023 02:13

Должно быть так:

Ты ерунду пишешь, так нельзя делать. Нужно указать свой локальный метод и прописать его в 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
Василий Наумкин
09.04.2024 01:45
Ошибка 500 Это не похоже на ошибку Nginx, это скорее всего ошибка PHP - надо смотреть его логи. Во...
futuris
Futuris
04.04.2024 05:56
Я просто немного запутался. Когда в абзаце &quot;Vesp/Core&quot; ты пишешь про &quot;новый trait Fil...
bezumkin
Василий Наумкин
20.03.2024 18:21
Volledig!
Андрей
14.03.2024 10:47
Василий! Как всегда очень круто! Моё почтение!
russelgal
russel gal
09.03.2024 17:17
А этот стоило написать хотя бы затем, чтобы получить комментарий от юзера, который ничего не писал ...
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 для бэкенда. Их можно обновлять, но э...