В этой части:
- создадим блоки товаров «С этим товаром также покупают» и «Интересные товары»
- создадим иконку корзины с количеством товаров
- подключим модальное окно с товарами в корзине
- перепишем всю логику store
Создадим блоки товаров типа: «С этим товаром также покупают»
Тут немножко поговорим об асинхронных запросах в Nuxt. Допустим мы хотим создать такой блок с товарами. Соответственно сервер должен уметь как-то генерировать список товаров и мы его загрузим, но вопрос куда?
Если мы создадим компонент типа AlsoBuyList то у него не будет метода asyncData, и мы не сможем его отрендерить на сервере. Если допустим для вас это не проблема, что в html который отдаёт сервер не будет этих блоков, то тогда вместо asyncData можно использовать mounted (или отложить загрузку до момента пока компонент будет в области видимости).
Но допустим мы хотим True SSR, тогда как вариант мы можем пользоваться методом asyncData родительской страницы. Это усложняет проект, так как где бы мы не хотели отрендерить список товаров, родитель должно контролировать эти списки и получать для них данные. Ужасно, медленно, неудобно, но мы получим труSSR.
Теперь поехали это делать.
Приводим наш Vuex к такому виду:
index.js
// function for Mock API
const sleep = m => new Promise(r => setTimeout(r, m))
const sampleSize = require('lodash.samplesize')
const categories = [
{
id: 'cats',
cTitle: 'Котики',
cName: 'Котики',
cSlug: 'cats',
cMetaDescription: 'Мета описание',
cDesc: 'Описание',
cImage: 'https://source.unsplash.com/300x300/?cat,cats',
products: []
},
{
id: 'dogs',
cTitle: 'Собачки',
cName: 'Собачки',
cSlug: 'dogs',
cMetaDescription: 'Мета описание',
cDesc: 'Описание',
cImage: 'https://source.unsplash.com/300x300/?dog,dogs',
products: []
},
{
id: 'wolfs',
cTitle: 'Волчки',
cName: 'Волчки',
cSlug: 'wolfs',
cMetaDescription: 'Мета описание',
cDesc: 'Описание',
cImage: 'https://source.unsplash.com/300x300/?wolf',
products: []
},
{
id: 'bulls',
cTitle: 'Бычки',
cName: 'Бычки',
cSlug: 'bulls',
cMetaDescription: 'Мета описание',
cDesc: 'Описание',
cImage: 'https://source.unsplash.com/300x300/?bull',
products: []
}
]
function getProductsByIds (products, productsImages, ids) {
const innerProducts = products.filter(p => p.id === ids.find(id => p.id === id))
if (!innerProducts) return null
return innerProducts.map(pr => {
return {
...pr,
images: productsImages.find(img => img.id === pr.id).urls,
category: categories.find(cat => cat.id === pr.category_id)
}
})
}
function getProduct (products, productsImages, productSlug) {
const innerProduct = products.find(p => p.pSlug === productSlug)
if (!innerProduct) return null
return {
...innerProduct,
images: productsImages.find(img => img.id === innerProduct.id).urls,
category: categories.find(cat => cat.id === innerProduct.category_id)
}
}
function addProductsToCategory (products, productsImages, category) {
const categoryInner = { ...category, products: [] }
products.map(p => {
if (p.category_id === category.id) {
categoryInner.products.push({
id: p.id,
pName: p.pName,
pSlug: p.pSlug,
pPrice: p.pPrice,
image: productsImages.find(img => img.id === p.id).urls
})
}
})
return categoryInner
}
function getBreadcrumbs (pageType, route, data) {
const crumbs = []
crumbs.push({
title: 'Главная',
url: '/'
})
switch (pageType) {
case 'category':
crumbs.push({
title: data.cName,
url: `/category/${data.cSlug}`
})
break
case 'product':
crumbs.push({
title: data.category.cName,
url: `/category/${data.category.cSlug}`
})
crumbs.push({
title: data.pName,
url: `/product/${data.pSlug}`
})
break
default:
break
}
return crumbs
}
export const state = () => ({
categoriesList: [],
currentCategory: {},
currentProduct: {
alsoBuyProducts: [],
interestingProducts: []
},
bredcrumbs: []
})
export const mutations = {
SET_CATEGORIES_LIST (state, categories) {
state.categoriesList = categories
},
SET_CURRENT_CATEGORY (state, category) {
state.currentCategory = category
},
SET_CURRENT_PRODUCT (state, product) {
state.currentProduct = product
},
SET_BREADCRUMBS (state, crumbs) {
state.bredcrumbs = crumbs
},
RESET_BREADCRUMBS (state) {
state.bredcrumbs = []
},
GET_PRODUCTS_BY_IDS () {}
}
export const actions = {
async getProductsListByIds ({ commit }) {
// simulate api work
const [products, productsImages] = await Promise.all(
[
this.$axios.$get('/mock/products.json'),
this.$axios.$get('/mock/products-images.json')
]
)
commit('GET_PRODUCTS_BY_IDS')
const idsArray = (sampleSize(products, 5)).map(p => p.id)
return getProductsByIds(products, productsImages, idsArray)
},
async setBreadcrumbs ({ commit }, data) {
await commit('SET_BREADCRUMBS', data)
},
async getCategoriesList ({ commit }) {
try {
await sleep(50)
await commit('SET_CATEGORIES_LIST', categories)
} catch (err) {
console.log(err)
throw new Error('Внутреняя ошибка сервера, сообщите администратору')
}
},
async getCurrentCategory ({ commit, dispatch }, { route }) {
await sleep(50)
const category = categories.find((cat) => cat.cSlug === route.params.CategorySlug)
// simulate api work
const [products, productsImages] = await Promise.all(
[
this.$axios.$get('/mock/products.json'),
this.$axios.$get('/mock/products-images.json')
]
)
const crubms = getBreadcrumbs('category', route, category)
await dispatch('setBreadcrumbs', crubms)
await commit('SET_CURRENT_CATEGORY', addProductsToCategory(products, productsImages, category))
},
async getCurrentProduct ({ commit, dispatch }, { route }) {
await sleep(50)
const productSlug = route.params.ProductSlug
// simulate api work
const [products, productsImages, alsoBuyProducts, interestingProducts] = await Promise.all(
[
this.$axios.$get('/mock/products.json'),
this.$axios.$get('/mock/products-images.json'),
dispatch('getProductsListByIds'),
dispatch('getProductsListByIds')
]
)
const product = getProduct(products, productsImages, productSlug)
const crubms = getBreadcrumbs('product', route, product)
await dispatch('setBreadcrumbs', crubms)
await commit('SET_CURRENT_PRODUCT', { ...product, alsoBuyProducts, interestingProducts })
}
}
getProductsByIds симуляция работы на api сервер
getProductsListByIds action который симулирует запрос к серверу. В данном случае он рандомно выбирает 5 товаров
await sleep(50)
const productSlug = route.params.ProductSlug
// simulate api work
const [products, productsImages, alsoBuyProducts, interestingProducts] = await Promise.all(
[
this.$axios.$get('/mock/products.json'),
this.$axios.$get('/mock/products-images.json'),
dispatch('getProductsListByIds'),
dispatch('getProductsListByIds')
]
)
const product = getProduct(products, productsImages, productSlug)
const crubms = getBreadcrumbs('product', route, product)
dispatch('setBreadcrumbs', crubms)
commit('SET_CURRENT_PRODUCT', { ...product, alsoBuyProducts, interestingProducts })
Этот код раньше у нас получал только инфу о товаре. Теперь же он будет получать инфу и про эти блоки alsoBuyProducts interestingProducts и класть её в товар.
Тут можно увидеть что мы батчим сразу и запросы к серверу и другие actions, которые делают запросы. Это лучше чем писать всё в стиле:
const products = await this.$axios.$get('/mock/products.json')
const productsImages = await this.$axios.$get('/mock/products-images.json')
const alsoBuyProducts = await dispatch('getProductsListByIds')
const interestingProducts = await dispatch('getProductsListByIds')
Так как в этом случае браузер будет выполнять их по очереди.
После этого на странице товара получаем такой объект из стора:
Дальше дело техники, на странице товара выводим
<h2>С этим товаром также покупают</h2>
<ProductsList :products="product.alsoBuyProducts" />
<h2>Возможно вам будет интересно</h2>
<ProductsList :products="product.interestingProducts" />
И создаём компонент ProductsList
ProductsList.vue
<template>
<div :class="$style.wrapper">
<div v-for="product in products" :key="product.id">
<nuxt-link :to="`/product/${product.pSlug}`">
<p>{{ product.pName }}</p>
<img
v-lazy="product.images.imgL"
:class="$style.image"
/>
</nuxt-link>
<p>Цена {{ product.pPrice }}</p>
<BuyButton :product="product" />
</div>
</div>
</template>
<script>
import BuyButton from '~~/components/common/BuyButton'
export default {
components: {
BuyButton
},
props: {
products: {
type: Array,
default: () => []
}
}
}
</script>
<style lang="scss" module>
.wrapper {
display: flex;
flex-wrap: wrap;
> div {
margin: 1em;
}
p {
max-width: 270px;
height: 35px;
}
}
.image {
width: 300px;
height: 300px;
object-fit: cover;
}
</style>
Он просто получает массив товаров и рендерит его. Получаем в результате:
Вы готовы к настоящему хардкору?
Честно говоря, первый раз делаю корзину client-side only, поэтому простите мои ошибки (буду рад советам в комментариях). Но чисто с академической стороны (для демонстрации) думаю, терпимо.
Для реализации модалки с корзиной мне понадобилось изменить и создать в общей сложности 24 файла!
Схема будет примерно такая:
- Храним в браузере id и количество товара и их порядковый номер, которые мы добавили в корзину.
- Пишем action, который будет получать массив id товаров и отдавать мета-инфу о товарах (название, цену, картинки и тд.).
- Пишем getter, который будет объеденять товар из корзины и мета-информацию.
- Наш vuex store и localstorage по умолчанию уже синхронизован, поэтому мы нигде не будем делать запрос к api для получения корзины.
- Пишем 100500 компонентов для рендера.
- Так как сервер ничего не знает о корзине, всё что связано с данными из корзины мы оборачиваем в <client-only>, чтобы не было ошибки что DOM не совпадает (на сервере и клиенте).
Теперь о реализации
В модуле корзины будете 2 массива:
- Первый products с такой структурой { qty, productId, order }
- Второй metaProducts здесь будет информация о товарах полученная от api (название, цена, картинки и тд.)
Первый и второй массив сами по себе бесполезны для рендеринга, мы хотим получить 1 массив где в каждом объекте сразу будет вся нужная информация. Для этого отлично подойдёт getter. Гетеры это аналог computed в компонентах, реагируют на изменения данных на которые они полагаются.
Напишем такой геттер
getProductsInCart: state => {
const products = []
state.products.map(p => {
const metaProduct = state.metaProducts.find(mp => mp.id === p.productId)
if (metaProduct) {
products.push({ ...p, meta: metaProduct })
}
})
return products.sort((a, b) => a.order - b.order)
}
Он берёт первый массив и проходит по нему. Если во втором массиве есть соответствующая мета-информация, то мы заносим объект вида
{ ...p, meta: metaProduct }
где p это { qty, productId, order }, в const products
После обхода всех элементов мы получаем новый массив products, который сортируем по order и возвращаем.
Вы наверное задаётесь вопросом, зачем такой странный merge. Дело в том, что метаинформация подтягивается от api отдельным запросом, после каждого изменения первого массива products. А это означает, что возникает такая ситуация, когда в первом массиве уже есть товар, а метаинформация ещё не загружена. Это легко может привести к ошибкам рендера, так как функции разных компонентов ожидают получить товар и его мета-данные.
Таким образом, только, когда мета-данные будут получены от api, этот геттер сделает merge двух массивов. Это существенно облегчает последующий рендер.
Пишем mutations
export const mutations = {
ADD_PRODUCT (state, productId) {
// if cart doesn't have product add it
if (!state.products.find(p => productId === p.productId)) {
state.products = [...state.products, { productId: productId, qty: 1, order: findMax(state.products, 'order') + 1 }]
}
},
REMOVE_PRODUCT (state, productId) {
state.products = Array.from(state.products.filter(prod => prod.productId !== productId))
},
SET_PRODUCT_QTY (state, { productId, qty }) {
state.products = [
...state.products.filter(prod => prod.productId !== productId),
{ ...state.products.find(prod => prod.productId === productId), qty }
]
},
SET_PRODUCTS_BY_IDS (state, products) {
state.metaProducts = products
}
}
ADD_PRODUCT — убеждается, что товара ещё нет, добавляет его с qty = 1 (то есть количество товара в корзине по умолчанию 1 шт.) и присваивает ему максимальный порядковый номер + 1.
Где за поиск этого номера отвечает простая функция
const findMax = (array, field) => {
if (!array || array.lenght === 0) return 1
return Math.max(...array.map(o => o[field]), 0)
}
Которая получает массив для перебора и поле объекта, которое будет сравнивать с одноименными полями других объектов массива.
REMOVE_PRODUCT — убирает товар
SET_PRODUCT_QTY — меняем количество товара в корзине
SET_PRODUCTS_BY_IDS — присваивает мета-инфу
Пишем actions
export const actions = {
async setProductsListByIds ({ commit, state }) {
// simulate api work
await sleep(50)
const [products, productsImages] = await Promise.all(
[
this.$axios.$get('/mock/products.json'),
this.$axios.$get('/mock/products-images.json')
]
)
const productsIds = state.products.map(p => p.productId)
await commit('SET_PRODUCTS_BY_IDS', mock.getProductsByIds(products, productsImages, productsIds))
},
async addProduct ({ commit, dispatch }, productId) {
// simulate api work
await sleep(50)
await commit('ADD_PRODUCT', productId)
await dispatch('setProductsListByIds')
},
async removeProduct ({ commit, dispatch }, productId) {
// simulate api work
await sleep(50)
await commit('REMOVE_PRODUCT', productId)
await dispatch('setProductsListByIds')
},
async setProductQuantity ({ commit, dispatch }, { productId, qty }) {
// simulate api work
await sleep(50)
await commit('SET_PRODUCT_QTY', { productId, qty })
await dispatch('setProductsListByIds')
}
}
После каждого изменения товара мы вызываем await dispatch(‘setProductsListByIds’) то есть загружаем мета-информацию от api.
Причина почему после каждого в том, что теоретически на api сервере может быть сложная система цен, которая зависит от количества товара в корзине, суммы заказа, количества позиций и так далее. И даже не смотря на то, что сервер не хранить сейчас информацию о корзине, это небольшой так задел на будущее.
Создаём модальное окно
Создаём новый плагин vue-modal.js
import Vue from 'vue'
import VModal from 'vue-js-modal'
export default async (context, inject) => {
Vue.use(VModal)
}
и подключаем его глобально на весь проект в nuxt.config.js
{ src: '~~/plugins/vue-modal.js', mode: 'client' },
Создаём компонент
mode: ‘client’ говорит о том, что нам не нужно его инициализировать на сервере
Создаём папку components/modals и файл CastomerCartModal.vue
CastomerCartModal.vue
<template>
<div>
<client-only>
<modal
name="customer-cart"
transition="pop-out"
height="95%"
width="95%"
:max-width="960"
:adaptive="true"
:scrollable="true"
:pivot-y="0.5"
:reset="true"
classes="v--modal-customer-cart"
@before-open="beforeOpen"
>
<div class="modal-wrapper content-padding">
<div class=" header-block">
<p class="h1-header">
Cart
</p>
<div class="close" @click="$modal.hide('customer-cart')">
<CloseOrDeleteButton />
</div>
</div>
<div v-if="getProductsInCart.length === 0" class="">
<p>
Товаров пока нет, но это легко можно исправить :)
</p>
</div>
<template v-else>
<div class="wrapper">
<template v-if="getAddedProduct">
<p class="added-product ">
You've added
</p>
<ProductsList class="" :products-from-cart="getAddedProduct" />
<p v-if="getProducts.length > 0" class="added-product ">
Previously added products
</p>
</template>
<ProductsList class="products" :products-from-cart="getProducts" />
</div>
<div>Total: {{ getAmount | round }}</div>
<div class="bottom">
<a class="button color-grey close-button" @click.prevent="$modal.hide('customer-cart')">
Close
</a>
<div class="amount-block">
<nuxt-link
to="/checkout"
class="button color-primary checkout-button"
>
To checkout
</nuxt-link>
</div>
</div>
</template>
</div>
</modal>
</client-only>
</div>
</template>
<script>
import { mapGetters } from 'vuex'
import ProductsList from '~~/components/cart/ProductsList.vue'
import CloseOrDeleteButton from '~~/components/common/input/CloseOrDeleteButton.vue'
import round from '~~/mixins/round.js'
export default {
components: {
ProductsList,
CloseOrDeleteButton
},
mixins: [round],
data () {
return {
addedProduct: null,
defaults: {
addedProduct: null
}
}
},
computed: {
...mapGetters({
getProductsInCart: 'cart/getProductsInCart'
}),
getAddedProduct () {
const product = this.getProductsInCart.find(
prod => prod.productId === this.addedProduct
)
if (product) {
return [product]
} else {
return null
}
},
getAmount () {
let amount = 0
if (this.getProductsInCart && this.getProductsInCart.length > 0) {
this.getProductsInCart.forEach(product => {
amount +=
parseFloat(product.meta.pPrice) *
parseInt(product.qty)
})
return amount
} else {
return 0
}
},
getProducts () {
if (this.addedProduct) {
return this.getProductsInCart.filter(
prod => prod.productId !== this.addedProduct
)
} else {
return this.getProductsInCart
}
}
},
watch: {
$route: function () {
this.$modal.hide('customer-cart')
},
getProductsInCart: function (newVal, oldVal) {
if (oldVal.length > 0) {
if (this.getProductsInCart.length === 0) {
this.$modal.hide('customer-cart')
}
}
}
},
methods: {
beforeOpen (event) {
if (!event.params) {
event.params = {}
}
if (event.params.addedProduct) {
this.addedProduct = event.params.addedProduct
} else {
this.addedProduct = this.defaults.addedProduct
}
}
}
}
</script>
<style lang="scss">
</style>
<style lang="scss" scoped>
.submit-error {
font-weight: 500;
color: #de0d0d;
// font-weight: 300;
font-size: 0.7em;
}
.modal-wrapper {
// border: 3px solid $accent-border-color;
background: #fff;
overflow-y: scroll;
// margin-top: 20px;
height: 100%;
display: flex;
flex-direction: column;
align-items: stretch;
}
.bottom {
flex-shrink: 0;
margin-bottom: 30px;
display: flex;
justify-content: flex-start;
// align-items: flex-start;
flex-direction: column;
margin-top: 16px;
@media screen and (min-width: 1024px) {
justify-content: space-between;
flex-direction: row;
align-items: flex-end;
padding-bottom: 50px;
}
.close-button {
display: none;
@media screen and (min-width: 1024px) {
display: block;
}
}
.amount-block {
.checkout-button {
margin-top: 5px;
width: 100%;
display: flex;
@media screen and (min-width: 640px) {
width: auto;
}
}
}
button.bttn-material-flat {
font-size: 1rem;
@media screen and (min-width: 1024px) {
font-size: 1.4rem;
}
}
}
p.added-product {
font-size: 1.6rem;
margin-bottom: 1rem;
}
.wrapper {
// height: 100%;
flex-grow: 1;
position: relative;
}
.header-block {
flex-shrink: 0;
// padding: 10px 20px;
margin-top: 20px;
// background: #f8fafb;
// font-size: 1.6rem;
position: relative;
margin-bottom: 1rem;
.close {
position: absolute;
right: 12px;
top: 0;
}
}
.pop-out-enter-active,
.pop-out-leave-active {
transition: all 0.5s;
}
.pop-out-enter,
.pop-out-leave-active {
opacity: 0;
transform: translateY(24px);
}
</style>
Теперь подробнее об этом компоненте.
Весь код обвёрнут в <client-only>, чтобы Vue не пытался его рендерить на сервере.
modal
- name=»customer-cart» имя модального окна должно быть уникальным, позволяет открывать/закрывать окно из любого компонента (так как мы ранее подключили этот плагин глобально)
- :adaptive=»true» делает его ширину не 95%, а адаптивной в зависимости от содержимого и ширины экрана (но когда экран маленький, она будет 95%)
- :scrollable=»true» лочить body запрещая тем самым скорлл страницы.
- :pivot-y=»0.5″ делаем окно по центру
- :reset=»true» пересоздаём компонент каждый раз после закрытия (устраняет баги всякого рода)
- classes=»v—modal-customer-cart» указываем свой стиль
- @before-open=»beforeOpen» эта функция вызывается каждый раз перед открытием модалки (об этом чуть позже)
<div class="close" @click="$modal.hide('customer-cart')">
<CloseOrDeleteButton />
</div>
CloseOrDeleteButton это просто svg иконка (для удобства спрятана в отдельный компонент.
click=»$modal.hide(‘customer-cart’)» по клику на кнопку закрыть, соответственно закрываем модальное окно по имени
<template v-if="getAddedProduct">
<p class="added-product ">
You've added
</p>
<ProductsList class="" :products-from-cart="getAddedProduct" />
<p v-if="getProducts.length > 0" class="added-product ">
Previously added products
</p>
</template>
<ProductsList class="products" :products-from-cart="getProducts" />
ProductsList это компонент с товарами для корзины, который мы пока не создали.
Когда мы нажимаем на кнопку купить, товар, который мы только что добавили, стоит особнячком от всех с надписью You’ve added, а остальные товары идут потом после Previously added products. Но если мы просто нажали на иконку корзины (то есть не добавляли товар, а захотели просмотреть, то getAddedProduct будет null и соответственно без никаких лишних надписей мы воводим все товары.
getAddedProduct и getProducts — это простые обвёртки, которые в зависимости от свойства addedProduct меняют своё содержимое на уместное.
<div>Total: {{ getAmount | round }}</div>
Этот странный символ | (пайп) на самом деле говорит, что нужно вызвать функцию round (которая округляет число до двух знаков) и возвращает значение.
Эту функцию для удобства переиспользования мы выделим в миксин mixinsround.js
export default {
filters: {
round (num) {
return Math.round((num + Number.EPSILON) * 100) / 100
}
}
}
И подключим миксин для этого компонента
import round from '~~/mixins/round.js'
...
mixins: [round]
При открытии модального окна мы можем указать дополнительные параметры, например
this.$modal.show('customer-cart', { addedProduct: this.product.id })
Объект указанный вторым аргументом передаётся в метод beforeOpen
methods: {
beforeOpen (event) {
if (!event.params) {
event.params = {}
}
if (event.params.addedProduct) {
this.addedProduct = event.params.addedProduct
} else {
this.addedProduct = this.defaults.addedProduct
}
}
}
Где мы, если есть аргументы присваиваем их в data()
data () {
return {
addedProduct: null,
defaults: {
addedProduct: null
}
}
},
После открытия окна мы должны получить данные из store.
import { mapGetters } from 'vuex'
...
computed: {
...mapGetters({
getProductsInCart: 'cart/getProductsInCart'
}),
...
Где как и в случае с actions указываем имя модуля через слеш.
Из-за того что мы используем «хитрую» логику для вывода товаров (в зависимости от того, добавил пользователь товар только что или просто открыл посмотреть) мы не используем getProductsInCart напрямую, а пишем две обвёртки.
getAddedProduct () {
const product = this.getProductsInCart.find(
prod => prod.productId === this.addedProduct
)
if (product) {
return [product]
} else {
return null
}
},
getProducts () {
if (this.addedProduct) {
return this.getProductsInCart.filter(
prod => prod.productId !== this.addedProduct
)
} else {
return this.getProductsInCart
}
}
Если мы нажмём на какой-то товар, мы хотим чтобы модальное окно закрылось (при переходе на страницу этого товара). Реализуем это так:
watch: {
$route: function () {
this.$modal.hide('customer-cart')
}
То есть при смене объекта route закрываем модалку.
Также мы следим за количеством товаров в коризне, если пользователь удалил последний товар, то мы закрываем модалку.
watch: {
...
getProductsInCart: function (newVal, oldVal) {
if (oldVal.length > 0) {
if (this.getProductsInCart.length === 0) {
this.$modal.hide('customer-cart')
}
}
}
...
Создаём ProductsList
В нём нужны такие функции:
- удаление товара
- изменения количества товара (с debounce то есть с умной задержкой, если пользователь начнёт тыкать по стрелке вверх)
- подсчет суммы
ProductsList.vue
<template>
<div v-if="productsFromCart.length > 0" :class="$style.wrapper">
<div
v-for="product in productsFromCart"
:key="product.productId"
:class="$style.product"
>
<template>
<CloseOrDeleteButton
:class="$style.remove"
button-type="delete"
@click.native="onRemoveClickHandler(product)"
/>
<nuxt-link :to="`/product/${product.meta.pSlug}`">
<img
v-lazy="product.meta.images.imgL"
:class="$style.image"
/>
</nuxt-link>
<nuxt-link :class="$style.pName" :to="`/product/${product.meta.pSlug}`">
<p>{{ product.meta.pName }}</p>
</nuxt-link>
<div>
<p>Price: </p>
<p>{{ product.meta.pPrice }}</p>
</div>
<div>
<p>Quantity:</p>
<input
:value="product.qty"
:class="$style.input"
type="number"
:min="1"
:max="1000"
@change.prevent="onQuantityChangeHandler($event, product)"
/>
</div>
<div>
<p>Amount:</p>
<p>{{ (product.meta.pPrice * product.qty) | round }}</p>
</div>
</template>
</div>
</div>
</template>
<script>
import CloseOrDeleteButton from '~~/components/common/input/CloseOrDeleteButton.vue'
import round from '~~/mixins/round'
import { mapActions } from 'vuex'
import debounce from 'lodash.debounce'
export default {
components: {
CloseOrDeleteButton
},
mixins: [round],
props: {
productsFromCart: {
type: Array,
default: () => []
}
},
methods: {
...mapActions({
setProductQuantity: 'cart/setProductQuantity',
removeProduct: 'cart/removeProduct'
}),
onRemoveClickHandler (product) {
this.removeProduct(product.productId)
},
onQuantityChangeHandler: debounce(function onQuantityChangeHandler (e, product) {
const qty = e.target.value
this.setProductQuantity({ productId: product.productId, qty })
}, 400)
}
}
</script>
<style lang="scss" module>
.input {
height: 20px;
}
.remove {
top: -15px;
position: absolute;
left: -30px;
z-index: 1;
}
.wrapper {
display: flex;
flex-wrap: wrap;
flex-direction: column;
.product {
position: relative;
margin: 1em;
display: flex;
flex-direction: row;
* {
margin-right: 10px;
}
.pName {
width: 150px;
}
}
p {
max-width: 270px;
height: 35px;
}
}
.image {
width: 75px;
height: 75px;
object-fit: cover;
}
</style>
Здесь нам нужны 2 actions для удаления и изменения количества
...mapActions({
setProductQuantity: 'cart/setProductQuantity',
removeProduct: 'cart/removeProduct'
}),
При изменении количества мы вызываем
onQuantityChangeHandler: debounce(function onQuantityChangeHandler (e, product) {
const qty = e.target.value
this.setProductQuantity({ productId: product.productId, qty })
}, 400)
Которая получает текущее значение e.target.value и вызывает setProductQuantity. И конечно же всё обвернём в debounce, который я предварительно скачал из lodash
import debounce from 'lodash.debounce'
Так же тут мы подключаем миксин round для округления суммы и мой компонент CloseOrDeleteButton
Который выглядит так:
CloseOrDeleteButton.vue
<template>
<div class="svg-icon-block">
<SvgClose :class="{'svg-icon-close': buttonType === 'close', 'svg-icon-delete': buttonType === 'delete'}" />
</div>
</template>
<script>
import SvgClose from '~~/assets/svg/baseline-close-24px.svg?inline'
export default {
components: {
SvgClose
},
props: {
buttonType: {
type: String,
default: 'close'
}
}
}
</script>
<style lang="scss" scoped>
.svg-icon-delete {
// background: #ddd;
fill: #ffb2a9;
border: 3px solid #ffb2a9;
transition: all .3s ease;
width: 20px;
height: 20px;
&:hover {
// background: #ddd;
fill: #fb3f4c;
border-color: #fb3f4c;
}
@media (--mobile) {
width: 20px;
height: 20px;
border-width: 3px;
}
}
.svg-icon-close {
background: hsl(0, 0%, 60%);
fill: #fff;
border: 8px solid hsl(0, 0%, 60%);
width: 20px;
height: 20px;
&:hover {
background: hsl(0, 0%, 33%);
fill: #fff;
border-color :hsl(0, 0%, 33%);
}
}
.svg-icon-block {
display: block;
cursor: pointer;
}
svg {
border-radius: 100%;
opacity: 0.7;
line-height: 0;
box-sizing: content-box;
// // noselect
// -webkit-user-select: none; /* webkit (safari, chrome) browsers */
// -moz-user-select: none; /* mozilla browsers */
// -khtml-user-select: none; /* webkit (konqueror) browsers */
// -ms-user-select: none; /* IE10+ */
}
</style>
Единственное, что тут примечательно — это то как мы импортируем svg
import SvgClose from '~~/assets/svg/baseline-close-24px.svg?inline'
?inline говорит, что мы хотим получить этот svg в виде компонента, что разворачивает его на сервере в html позволяя нам задавать стили (то есть например менять цвет этого svg).
В итоге
Получаем модальное окно с товарами. Бонусом идёт синхронизация между вкладками (можно открыть 2 вкладки и менять модалку, всё синхронизируется за счет общего LocalStorage).
Добавим иконку корзины
Создаём файл
componentsheaderCartButton.vue
<template>
<div :class="$style.block">
<client-only>
<a
:href="'#'"
:class="$style.cartButton"
:disabled="!productsQuantity > 0 "
@click.prevent="onClickHandler"
>
<div v-if="productsQuantity > 0" :class="$style.quantity">
{{ productsQuantity }}
</div>
<CartSvg :class="$style.svg1" />
</a>
</client-only>
</div>
</template>
<script>
import { mapState } from 'vuex'
import CartSvg from '~~/assets/svg/shopping-cart.svg?inline'
export default {
components: {
CartSvg
},
computed: {
...mapState({
products: state => state.cart.products
}),
productsQuantity () {
if (this.products) {
return this.products.length
} else return 0
}
},
methods: {
onClickHandler () {
this.$modal.show('customer-cart')
}
}
}
</script>
<style lang="scss" module>
.block {
position: relative;
}
.cartButton {
position: relative;
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
width: 68px;
height: 72px;
text-align: center;
transition: all 0.3s ease-in-out;
}
.svg1 {
margin-right: 3px;
width: 40px;
fill: #000;
// noselect
-webkit-user-select: none; /* webkit (safari, chrome) browsers */
-moz-user-select: none; /* mozilla browsers */
-khtml-user-select: none; /* webkit (konqueror) browsers */
-ms-user-select: none; /* IE10+ */
}
.quantity {
position: absolute;
right: 5px;
top: 5px;
border-radius: 50px;
background-color: #fb3f4c;
width: 20px;
color: #fff;
height: 20px;
text-align: center;
line-height: 20px;
font-size: .8rem;
font-weight: 600;
// noselect
-webkit-user-select: none; /* webkit (safari, chrome) browsers */
-moz-user-select: none; /* mozilla browsers */
-khtml-user-select: none; /* webkit (konqueror) browsers */
-ms-user-select: none; /* IE10+ */
}
</style>
И подключаем его в наш Header.
Так как нам нужно только количество товара в корзине (без мета-информации) мы получаем товары не через геттер, а напрямую
...mapState({
products: state => state.cart.products
}),
Этот компонент, выводит количество товара в корзине и открывает нашу модалку при нажатии.
Выглядит он так