Корзина товаров
Каталог товаров вывели и оформили, теперь нужно сделать корзину, куда будем их складывать.
Авторизации на сайте у нас нет, значит корзина будет храниться не в базе данных, а на клиенте. Для долговременного хранения мы используем 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
👍
👎
❤️
🔥
😮
😢
😀
😡
735
30.06.2022, 15:00:27
11 комментариев
Александр Наумов
11.08.2022, 17:52:31
Класс!!
Для тех, кто следует инструкциям из урока, нужно добавить @click="showCart = true" в:
что бы было:
Василий Наумкин
11.08.2022, 18:46:14
Рад, что тебе нравится!
Спасибо, поправил текст!
Александр Наумов
19.02.2023, 23:55:30
Василий, добрый день!
Хочу к кнопкам добавить toast.
В конфиге:
В файле: frontend/src/site/store/index.js
Выдает ошибку: Cannot read properties of undefined (reading 'success')
Не пойму, вроде все сделал правильно, подскажи, пожалуйста, что не так?
Василий Наумкин
20.02.2023, 04:56:23
В store нет никакого this.$toast, это отдельное такое место, в котором всё this - это сам store.
Он не должен вызывать ничего снаружи, это его нужно вызывать из своего компонента:
Александр Наумов
20.02.2023, 13:27:46
Василий, добрый день!
У меня в: frontend/src/site/store/index.js
А в frontend/src/site/nuxt.config.js
И выводит ошибку: Cannot read properties of undefined (reading 'error') ((
Василий Наумкин
20.02.2023, 14:28:40
В предыдущем сообщении я был не прав, внутри store есть и this.$toast, и многое другое - проверил на этом сайте. Но вот this.$store там у меня нет.
У тебя, судя по коду, в этом месте и возникает ошибка, которая прилетает в catch, где идёт попытка вывести уведомление. Я тебе писал код для пример вызова в компоненте (на странице), а не в самом store.
Вызови в этом методе console.log(Object.keys(this)) и посмотри в консоли, к чему именно там можно обращаться.
Еще проверь работу $toast на обычных страницах, прежде чем запихивать его в store, чего я не рекомендую вообще делать.
Александр Наумов
20.02.2023, 22:09:46
Спасибо!
Проблема была в подключении '@nuxtjs/toast', он не подтягивался с @vesp, то есть такая конструкция не работает:
Пришлось установить @nuxtjs/toast во frontend.
Василий, ты рекомендуешь:
Но ведь на кнопках "Добавить в корзину" по клику вызывается метод addToCart, который находится в store.
Получается на событие клик "Добавить в корзину" нужно вешать два метода (addToCart и метод для toast)?
Запустил в store
console.log(Object.keys(this))
Получил:
Василий Наумкин
21.02.2023, 04:41:01
Странно, в зависимостях указан - https://github.com/bezumkin/vesp-frontend/blob/master/package.json
Нужно вешать один локальный метод в этом же компоненте. А он тебе вызовет всё, что надо - и обработает как надо.
Теперь знаешь, как проверять наличие чего-то в объектах
Александр Наумов
22.02.2023, 00:11:45
Василий, спасибо большое!
Только вот, на клик нужно вешать два метода, ведь по клику еще нужно сделать запись в 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.
Василий Наумкин
22.02.2023, 05:13:30
Ты ерунду пишешь, так нельзя делать. Нужно указать свой локальный метод и прописать его в methods компонента - что я тебе и написал в своём примере.
Видимо тебя смущают одинаковые названия. Давай так:
Чуть ниже
Так понятно?
Почитай уже документацию Vuex, где написано, для чего он используется:
Понимаешь? Он не должен вызывать показ всплывающих сообщений, это, можно сказать, локальная база данных приложения.
Нет, не делал
Александр Наумов
22.02.2023, 19:10:48
Спасибо! Да, мне здесь подучиться нужно.
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, из-за чего запросы не улетали на фай...
Василий Наумкин
01.07.2024, 11:56:41
Да, верно, именно так.
А в контроллере, скорее всего, ловить данные методом post.
Василий Наумкин
26.06.2024, 09:38:15
О, точно, вылезает если не залогинен.
Спасибо, исправил!
Василий Наумкин
09.04.2024, 04:45:01
> Ошибка 500
Это не похоже на ошибку Nginx, это скорее всего ошибка PHP - надо смотреть его логи.
...
Уровни подписки
Спасибо!
500 ₽ в месяц
Эта подписка ничего не даёт, просто возможность сказать спасибо за мои заметки. Подписчики отмечаются зелёненьким цветом в комментариях.
Большое спасибо!
1 000 ₽ в месяц
И эта подписка не даёт ничего, кроме оранжевого цвета в комментариях и возможности сказать спасибо, но уже большое!