Pull to refresh

Создаем интернет-магазин на Nuxt.js 2 пошаговое руководство Часть 3

Reading time 20 min
Views 15K


Как и обещал продолжаем.


В этой части:


  • создадим блоки товаров "С этим товаром также покупают" и "Интересные товары"
  • создадим иконку корзины с количеством товаров
  • подключим модальное окно с товарами в корзине
  • перепишем всю логику 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 файла!


Схема будет примерно такая:


  1. Храним в браузере id и количество товара и их порядковый номер, которые мы добавили в корзину.
  2. Пишем action, который будет получать массив id товаров и отдавать мета-инфу о товарах (название, цену, картинки и тд.).
  3. Пишем getter, который будет объеденять товар из корзины и мета-информацию.
  4. Наш vuex store и localstorage по умолчанию уже синхронизован, поэтому мы нигде не будем делать запрос к api для получения корзины.
  5. Пишем 100500 компонентов для рендера.
  6. Так как сервер ничего не знает о корзине, всё что связано с данными из корзины мы оборачиваем в <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 не пытался его рендерить на сервере.



  • 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 (которая округляет число до двух знаков) и возвращает значение.


Эту функцию для удобства переиспользования мы выделим в миксин mixins\round.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).


Добавим иконку корзины


Создаём файл


components\header\CartButton.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
    }),

Этот компонент, выводит количество товара в корзине и открывает нашу модалку при нажатии.


Выглядит он так


Итоги



Это третья часть моего незамысловатого примера использования Nuxt.
Мы создали блоки товаров, переделали всю логику работы с корзиной, добавили модалки.
Теперь это чудо всё больше похоже на интернет-магазин.
К сожалению в одной статье не получается уместить такой объем, который был задуман в предыдущей. Поэтому страницу оформления заказа (и всё остальное) будем делать уже в следующей части.


Послесловие


Буду рад выслушать ваши пожелания что прикрутить к магазину, добавить показать, а так же вопросы и не забудьте поддержать плюсиками, если вам зашло.


Спасибо за чтение!

Tags:
Hubs:
+4
Comments 2
Comments Comments 2

Articles