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

bns2eafwvg8dzlyx7i2ng0wmpsw.jpeg

Первая часть тут

Продолжаем разработку нашего интернет магазина. В этой части будет:


  • нормальная загрузка картинок по статическим адресам
  • генерация хлебных крошек на клиенте
  • страница товара
  • шапка
  • рабочая кнопка купить с синхронизацией товаров между вкладками (и сессиями)

Как можно увидеть вытаскивать случайные картинки с Unsplash довольно медленная идея. Лучше заранее указать в товарах статические картинки (для демонстрации). Поэтому давайте получим нужны адреса картинок и положим их в проект.


Пишем «загрузчик»

Для этого напишем простой асинхронный загрузчик.

const products = require('../static/mock/products.json')

const got = require('got')
const QS = require('querystring')
const API_KEY = ''

const fs = require('fs')
const { promisify } = require('util')

const writeFileAsync = promisify(fs.writeFile)

async function fetchApiImg (searchQuery) {
  try {
    const query = QS.stringify({ key: API_KEY, q: searchQuery, per_page: '3', image_type: 'photo' })
    const resPr = got(`https://pixabay.com/api/?${query}`)
    const json = await resPr.json()
    if (json.hits && json.hits.length > 0 && json.hits[0].largeImageURL && json.hits[0].webformatURL) {
      return {
        imgXL: json.hits[0].largeImageURL,
        imgL: json.hits[0].webformatURL
      }
    } else {
      throw 'no image'
    }
  } catch (error) {
    return {
      imgXL: null,
      imgL: null
    }
  }
}
async function getImagesUrls () {
  const imagesUrl = []
  await Promise.all(
    products.map(async product => {
      const productName = product.pName.split(' ')[0]
      const imgUrls = await fetchApiImg(productName)
      imagesUrl.push({ id: product.id, urls: imgUrls })
    })
  )
  return imagesUrl
}
async function main () {
  try {
    const imagesUrls = await getImagesUrls()
    await writeFileAsync('./static/mock/products-images.json', JSON.stringify(imagesUrls), { flag: 'w+' })
  } catch (error) {
    console.log(error)
  }
}
main()

API_KEY нужно поставить свой (получаем его от сервиса за 1 мин).
Что примечательно, так это то что без особой возни, на моем компьютере этот скрипт выполняется за 1 сек, а это 500 асинхронных запросов (не большой такой ddos).

Для тех кто не особо понимает как работает скрипт вот подробное объяснение:


Про скрипт
  await Promise.all(
    products.map(async product => {
      const productName = product.pName.split(' ')[0]
      const imgUrl = await fetchApiImg(productName)
      imagesUrl.push({ id: product.id, url: imgUrl })
    })
  )

На каждом элементе массива из товаров мы вызываем асинхронную функцию, которая получает url картинки (делая запрос), добавляет его в массив imagesUrl и возвращает (неявно) Promise. await Promise.all означает что мы ждём завершения всех промисов и двигаемся дальше.

product.pName.split (' ')[0] — получаем первое слово из названия товара
imagesUrl этот массив будет хранить id товара и url фотографии для него.

    const query = QS.stringify({ key: API_KEY, q: searchQuery, per_page: '3', image_type: 'photo' })
    const resPr = got(`https://pixabay.com/api/?${query}`)
    const json = await resPr.json()
    if (json.hits && json.hits.length > 0 && json.hits[0].largeImageURL && json.hits[0].webformatURL) {
      return {
        imgXL: json.hits[0].largeImageURL,
        imgL: json.hits[0].webformatURL
      }
    } else {
      throw 'no image'
    }

QS.stringify стандартная нодовская функция querystring для создания (и не только) параметров запроса (можно и руками конечно написать, но зачем?)
got (https://pixabay.com/api/?${query}) получаем промис для запроса
await resPr.json () исполняем его, а результат парсим как json


Правим Store для картинок

Теперь изменим сервер, чтобы он использовал эти картинки.


store/index.js
// function for Mock API
const sleep = m => new Promise(r => setTimeout(r, m))
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 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
}
export const state = () => ({
  categoriesList: [],
  currentCategory: {},
  currentProduct: {}
})
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
  }
}
export const actions = {
  async getCategoriesList ({ commit }) {
    try {
      await sleep(1000)
      await commit('SET_CATEGORIES_LIST', categories)
    } catch (err) {
      console.log(err)
      throw new Error('Внутреняя ошибка сервера, сообщите администратору')
    }
  },
  async getCurrentCategory ({ commit }, { route }) {
    await sleep(1000)
    const category = categories.find((cat) => cat.cSlug === route.params.CategorySlug)

    const [products, productsImages] = await Promise.all(
      [
        await this.$axios.$get('/mock/products.json'),
        await this.$axios.$get('/mock/products-images.json')
      ]
    )

    await commit('SET_CURRENT_CATEGORY', addProductsToCategory(products, productsImages, category))
  }
}

Тут примечателен только этот кусок:

    const [products, productsImages] = await Promise.all(
      [
        await this.$axios.$get('/mock/products.json'),
        await this.$axios.$get('/mock/products-images.json')
      ]
    )

Так обычно батчатся асинхронные запросы к api. Можно хоть 20 штук так сделать и они будут загружаться параллельно.

И соответствующие Vue компоненты чуток подправить (не буду тут писать).

Получим в итоге что-то вроде:
tltren2eawefry1vbt_-0worths.png


Давайте добавим шапку

Создаём компонент


Header.vue





И добавляем его в default.vue


default.vue

Наверное вы заметили что пропал стиль для mainWrapper .
Это не спроста, оборачивая весь шаблон в некий max-width мы лишаем себя гибкости. Как вариант можно создать миксин в файле global-variables.scss.
Вида

@mixin globalWrapper {
  max-width: 1280px;
  margin: 0 auto;
  padding:  0 20px;
}
$basic-bg-color: #fcc000;

Дальше можно в нужном месте делать одинаковые отступы по сайту. Например в компоненте Header

.header {
  @include globalWrapper;
  display: flex;
}

Во всех компонентах нужно включить этот миксин в необходимых местах.

Получаем такую шапку на десктопе:

29nf-adp_6vk1xbfbiqw4gncf-e.png

И на мобильном:

lekxcyrax3f8ov1en-l_ldc55ty.png


Создаём страницу с товаром

По аналогии со страницей категории создаём _ProductSlug.vue


_ProductSlug.vue




Из интересного разве что, то что картинка кликабельная

        
          
        

Где imgL среднее изображение, а imgL большое.

Получаем такой результат

d3tdo7imbo3pqjoimbuqizgbbi4.png


Создаём хлебные крошки

Для начала немного философии. Так как nuxt не работает с meta информацией в роутах, а именно я говорю что например route объект для категории будет выглядеть так

{                                                                                                                                                                                              
  name: 'category-CategorySlug',
  meta: [
    {}
  ],
  path: '/category/dogs',
  hash: '',
  query: {},
  params: {
    CategorySlug: 'dogs'
  },
  fullPath: '/category/dogs',
  matched: [
    {
      path: '/category/:CategorySlug?',
      regex: /^\/category(?:\/((?:[^\/]+?)))?(?:\/(?=$))?$/i,
      components: [Object],
      instances: {},
      name: 'category-CategorySlug',
      parent: undefined,
      matchAs: undefined,
      redirect: undefined,
      beforeEnter: undefined,
      meta: {},
      props: {}
    }
  ]
}

То мы не можем вытащить отсюда полезной информации для автоматической генерации крошек.

Есть много способов как реализовать их, например можно получать с сервера уже готовые крошки, но для демонстрации мы будем использовать другой метод, а именно:


Приведём файл Vuex к такому виду:


index.js
// function for Mock API
const sleep = m => new Promise(r => setTimeout(r, m))
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 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: {},
  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 = []
  }
}
export const actions = {
  async setBreadcrumbs ({ commit }, data) {
    await commit('SET_BREADCRUMBS', data)
  },
  async getCategoriesList ({ commit }) {
    try {
      await sleep(300)
      await commit('SET_CATEGORIES_LIST', categories)
    } catch (err) {
      console.log(err)
      throw new Error('Внутреняя ошибка сервера, сообщите администратору')
    }
  },
  async getCurrentCategory ({ commit, dispatch }, { route }) {
    await sleep(300)
    const category = categories.find((cat) => cat.cSlug === route.params.CategorySlug)

    const [products, productsImages] = await Promise.all(
      [
        await this.$axios.$get('/mock/products.json'),
        await 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(300)
    const productSlug = route.params.ProductSlug
    const [products, productsImages] = await Promise.all(
      [
        await this.$axios.$get('/mock/products.json'),
        await this.$axios.$get('/mock/products-images.json')
      ]

    )
    const product = getProduct(products, productsImages, productSlug)
    const crubms = getBreadcrumbs('product', route, product)
    await dispatch('setBreadcrumbs', crubms)
    await commit('SET_CURRENT_PRODUCT', product)
  }

}

Тут нужно всё разобрать по пунктам.
Создаём функцию для получения массива крошек

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
}

Она работает таким образом: мы вызываем её 1 раз и передаём в неё текущий route, data (из которой мы будем вытягивать мета-информацию и pageType который мы передаём в зависимости от типа страницы с которой вызываем эту функцию.

В случае страницы товара мы добавляем и саму категорию в крошки и товар на который она указывает.

Соответственно сам товар должна хранить информацию о своих родителях (и о прародителях если этот товар лежит в категории 2-го уровня и тд.)

То есть в случае товара мы вызываем эту функцию ещё на этапе получения инфы от api

const crubms = getBreadcrumbs('product', route, product)

Что не самое оптимальное решение, так как нам нужно сначала получить запрос о товаре, а потом возможно придётся делать второй запрос к серверу. В данном случае этого не происходить так как мы генерируем крошки на клиенте.

В итоге мы в store получаем такую структуру
pneuepgnrcgqdpg5tp06ntk8q9e.png


Создаём компонент хлебных крошек

Дальше это дело техники вывести эту информацию, создаём компонент Breadcrumbs.vue


Breadcrumbs.vue





И добавляем его в наш layout, получаем такой результат:

hqqbu6xmzd6r9d38y84frtcykzs.png

ftc-97pjrn38hchjcce0zctihro.png

Так же из логики работы компонента возникает небольшая проблема. Допустим мы не хотим чтобы на главной странице выводились эти крошки. Есть несколько способов, мы же ударим в лоб и напишем костыль.


Костыль

Создаём middlware

export default async function ({ store, route }) {
  if (route.matched.length > 0) {
    await Promise.all(route.matched.map(async ({ name }) => {
      if (name === 'index') {
        await store.commit('RESET_BREADCRUMBS')
      }
    }))
  }
}

И подключаем его в nuxt.config.js

  router: {
    middleware: ['resetBreacrumbs'],
    prefetchLinks: false
  },

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

Что это за магазин в котором ничего нельзя купить, срочно пишем корзину.


Корзина

Так как у нас нет api сервера, который будет хранить сессии мы будем хранить всё в localStorage
Можно конечно же сделать свою реализацию, так как Vuex предоставляет watcherы на изменения состояния. Мы можем на каждое изменение стейта заносить его в localStorage, но что если мы потом изменим структуру, а у пользователя будет неверная структура храниться, нужно будет или механизм миграции писать, или версионирования. Есть более просто решения, используем готовый модуль.


nuxt-vuex-localstorage

Добавим в проект nuxt-vuex-localstorage и в nuxt.config.js в модулях подключаем его

    ['nuxt-vuex-localstorage', {
      ...(isDev && {
        mode: 'debug'
      }),
      localStorage: ['cart'] //  If not entered, "localStorage” is the default value
    }]

По умолчанию этот модуль не хитро шифрует localStorage на клиенте (может быть в целях безопасности, чтобы «сторонние» скрипты просто так не получили доступ к информации), поэтому для разработки отключаем это с помощью mode: 'debug'. Также указываем наш модуль Vuex с которым этот плагин будет работать cart.


Создадим новый Vuex модуль


cart.js
// function for Mock API
const sleep = m => new Promise(r => setTimeout(r, m))

export const state = () => ({
  products: [],
  version: '0.0.1'

})
export const mutations = {
  ADD_PRODUCT (state, product) {
    // if cart doesn't have product add it
    if (!state.products.find(p => product.id === p.id)) {
      state.products = [...state.products, product]
    }
  },
  SET_PRODUCT (state, { productId, data }) {
    state.products = [...state.products.filter(prod => prod.id !== productId), data]
  },
  REMOVE_PRODUCT (state, productId) {
    state.products = Array.from(state.products.filter(prod => prod.id !== productId))
  }

}
export const actions = {
  async addProduct ({ commit }, data) {
    await sleep(300)
    await commit('ADD_PRODUCT', data)
  },
  async removeProduct ({ commit }, productId) {
    await sleep(300)
    await commit('REMOVE_PRODUCT', productId)
  }

}

version: '0.0.1' этот параметр стейта используется для решения конфликта версий текущего storage у пользователя и у нашего сайта.

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


Создаём кнопку купить


BuyButton.vue





Здесь есть несколько интересных моментов.


  • в mapActions мы задаём путь указывая имя модуля cart/addProduct
  • в mapState делаем то же самое, но через анонимную функцию, которая получает объект state state => state.cart.products
  • храним флаг на случай если товар уже в корзине isProductAdded
  • если товара нет в корзине, то будет кнопка купить, если уже есть, то выводим ссылку при нажатии на которую товар удаляется из корзины.

Подключаем этот компонент в карточку товара и на страницу товара.

В итоге получается такое вот чудо

ecqawm4ae1nwufqthwvlfmnhbeu.png

wmnlp4m-gbzlplyetb_ort02b-m.png

Мы можем закрыть вкладку или открыть несколько вкладок, вся информация будет оставаться в браузере и синхронизировать по вкладкам (а также шифроваться в проде).


Итоги


  • Код проекта: на Github тыц.
  • Потыкать : тыц.

Это вторая часть моего незамысловатого примера использования Nuxt.
В следующей статье я хочу реализовать возможность изменять количество товара в корзине, удалять их, просматривать все товары используя модальные окна.

А также создать страницу оформления заказа, блоки с рекомендуемыми товарами и добавить микроразметку.


Послесловие

Ребята, я стараюсь делать быстро как могу, но на каждую статью уходит минимум 6 часов времени. Мне бы хотелось лучше понять насколько такой формат интересный.

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

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

© Habrahabr.ru