Корзина товаров
Каталог товаров вывели и оформили, теперь нужно сделать корзину, куда будем их складывать.
Авторизации на сайте у нас нет, значит корзина будет храниться не в базе данных, а на клиенте. Для долговременного хранения мы используем 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 © 2020 — {{ 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>
Как видите, мы просто вызываем заранее прописанные методы из хранилища в любом нужном нам месте приложения. Теперь при добавлении товара в корзину кнопка активируется и покажет итоговую сумму корзины.
Кстати говоря, состояние нашего хранилища во время разработки всегда можно посмотреть а браузерном расширении 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')
},
}
Таким же образом из 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>
Вот так по кирпичикам и собирается наше веб-приложение. На следующем занятии будем оформлять заказы.
Все изменения урока одним коммитом на Github.
0
👍
👎
❤️
🔥
😮
😢
😀
😡
764
30.06.2022 15:00:27
11 комментариев
Класс!!
Для тех, кто следует инструкциям из урока, нужно добавить @click="showCart = true" в:
что бы было:
Рад, что тебе нравится!
Спасибо, поправил текст!
Василий, добрый день!
Хочу к кнопкам добавить toast.
В конфиге:
В файле: frontend/src/site/store/index.js
Выдает ошибку: Cannot read properties of undefined (reading 'success')
Не пойму, вроде все сделал правильно, подскажи, пожалуйста, что не так?
В store нет никакого this.$toast, это отдельное такое место, в котором всё this - это сам store.
Он не должен вызывать ничего снаружи, это его нужно вызывать из своего компонента:
Василий, добрый день!
У меня в: frontend/src/site/store/index.js
А в frontend/src/site/nuxt.config.js
И выводит ошибку: Cannot read properties of undefined (reading 'error') ((
В предыдущем сообщении я был не прав, внутри store есть и this.$toast, и многое другое - проверил на этом сайте. Но вот this.$store там у меня нет.
У тебя, судя по коду, в этом месте и возникает ошибка, которая прилетает в catch, где идёт попытка вывести уведомление. Я тебе писал код для пример вызова в компоненте (на странице), а не в самом store.
Вызови в этом методе console.log(Object.keys(this)) и посмотри в консоли, к чему именно там можно обращаться.
Еще проверь работу $toast на обычных страницах, прежде чем запихивать его в store, чего я не рекомендую вообще делать.
Спасибо!
Проблема была в подключении '@nuxtjs/toast', он не подтягивался с @vesp, то есть такая конструкция не работает:
Пришлось установить @nuxtjs/toast во frontend.
Василий, ты рекомендуешь:
Но ведь на кнопках "Добавить в корзину" по клику вызывается метод addToCart, который находится в store.
Получается на событие клик "Добавить в корзину" нужно вешать два метода (addToCart и метод для toast)?
Запустил в store
console.log(Object.keys(this))
Получил:
Странно, в зависимостях указан - https://github.com/bezumkin/vesp-frontend/blob/master/package.json
Нужно вешать один локальный метод в этом же компоненте. А он тебе вызовет всё, что надо - и обработает как надо.
Теперь знаешь, как проверять наличие чего-то в объектах
Василий, спасибо большое!
Только вот, на клик нужно вешать два метода, ведь по клику еще нужно сделать запись в store.
Должно быть так:
Кнопка "Добавить товар в корзину" находится в двух местах: 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.
Ты ерунду пишешь, так нельзя делать. Нужно указать свой локальный метод и прописать его в methods компонента - что я тебе и написал в своём примере.
Видимо тебя смущают одинаковые названия. Давай так:
Чуть ниже
Так понятно?
Почитай уже документацию Vuex, где написано, для чего он используется:
Понимаешь? Он не должен вызывать показ всплывающих сообщений, это, можно сказать, локальная база данных приложения.
Нет, не делал
Спасибо! Да, мне здесь подучиться нужно.
bezumkin.ru
Личный сайт Василия Наумкина
Прямой эфир
Василий Наумкин
04.02.2025 19:27:08
Я таким давно не занимаюсь и с MODX не работаю.
Попробуйте обратиться к ребятам с modx.pro.
Василий Наумкин
23.12.2024 05:33:00
В MODX сначала создали проблему, автоматически генерируя адреса, а потом "решили" заморозкой.
Так ч...
Дмитрий
14.12.2024 09:10:38
Василий, прошу прощения, тупанул, не разобрался сразу. Фреймворк отличный! "Чистый лист" на vue, рис...
Василий Наумкин
05.12.2024 20:01:14
В итоге основная ошибка была в неправильном общем root в Nginx, из-за чего запросы не улетали на фай...
Василий Наумкин
01.07.2024 11:56:41
Да, верно, именно так.
А в контроллере, скорее всего, ловить данные методом post.
Василий Наумкин
26.06.2024 09:38:15
О, точно, вылезает если не залогинен.
Спасибо, исправил!
Уровни подписки
Спасибо!
500 ₽ в месяц
Эта подписка ничего не даёт, просто возможность сказать спасибо за мои заметки. Подписчики отмечаются зелёненьким цветом в комментариях.
Большое спасибо!
1 000 ₽ в месяц
И эта подписка не даёт ничего, кроме оранжевого цвета в комментариях и возможности сказать спасибо, но уже большое!