
Первая часть тут
Продолжаем разработку нашего интернет магазина. В этой части будет:
- нормальная загрузка картинок по статическим адресам
- генерация хлебных крошек на клиенте
- страница товара
- шапка
- рабочая кнопка купить с синхронизацией товаров между вкладками (и сессиями)
Как можно увидеть вытаскивать случайные картинки с 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 для картинок
Теперь изменим сервер, чтобы он использовал эти картинки.
// 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 компоненты чуток подправить (не буду тут писать).
Получим в итоге что-то вроде:

Давайте добавим шапку
Создаём компонент
<template> <div :class="$style.wrapper"> <div :class="$style.header"> <n-link :class="$style.logo" to="/"> <p> Хвостики </p> </n-link> </div> </div> </template> <script> export default { } </script> <style lang="scss" module> .wrapper { background-color: $basic-bg-color; height: 70px; } .header { @include globalWrapper; display: flex; } .logo { font-size: 2.2em; font-weight: 700; opacity: 1; color: #000; text-decoration: none; } </style>
И добавляем его в default.vue
<nuxt />
Наверное вы заметили что пропал стиль для 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; }
Во всех компонентах нужно включить этот миксин в необходимых местах.
Получаем такую шапку на десктопе:

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

Создаём страницу с товаром
По аналогии со страницей категории создаём _ProductSlug.vue
<template> <div :class="$style.page"> <div :class="$style.topBlock"> <div :class="$style.topLeftBlock"> <a :href="product.images.imgXL" target="_blank"> <img v-lazy="product.images.imgL" :class="$style.image" /> </a> </div> <div :class="$style.topRightBlock"> <h1>{{ product.pName }}</h1> <p>Цена: {{ product.pPrice }}</p> </div> </div> <h2>Описание</h2> <p>{{ product.pDesc }}</p> </div> </template> <script> import { mapState } from 'vuex' export default { async asyncData ({ app, params, route, error }) { try { await app.store.dispatch('getCurrentProduct', { route }) } catch (err) { console.log(err) return error({ statusCode: 404, message: 'Товар не найдена или сервер не доступен' }) } }, computed: { ...mapState({ product: 'currentProduct' }) }, head () { return { title: this.product.pTitle, meta: [ { hid: 'description', name: 'description', content: this.product.pMetaDescription } ] } } } </script> <style lang="scss" module> .page { @include globalWrapper; } .image { width: 400px; height: auto; } .topBlock { padding-top: 2em; display: flex; .topLeftBlock { display: flex; } .topRightBlock { padding-left: 2em; display: flex; flex-direction: column; } } </style>
Из интересного разве что, то что картинка кликабельная
<a :href="product.images.imgXL" target="_blank"> <img v-lazy="product.images.imgL" :class="$style.image" /> </a>
Где imgL среднее изображение, а imgL большое.
Получаем такой результат

Создаём хлебные крошки
Для начала немного философии. Так как 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 к такому виду:
// 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 получаем такую структуру

Создаём компонент хлебных крошек
Дальше это дело техники вывести эту информацию, создаём компонент Breadcrumbs.vue
<template> <div v-if="bredcrumbs && bredcrumbs.length > 0" :class="$style.breadcrumbs"> <ul> <li v-for="cr in bredcrumbs" :key="cr.url"> <n-link :to="cr.url"> {{ cr.title }} </n-link> </li> </ul> </div> </template> <script> import { mapState } from 'vuex' export default { computed: { ...mapState({ bredcrumbs: 'bredcrumbs' }) } } </script> <style lang="scss" module> /* Style the list */ .breadcrumbs { background-color: #eee; padding: 10px 16px; ul { list-style: none; @include globalWrapper; li { display: inline; font-size: 18px; + ::before { padding: 8px; color: black; content: '/\00a0'; } a { color: #0275d8; text-decoration: none; &:hover { color: #01447e; text-decoration: underline; } } } } } </style>
И добавляем его в наш layout, получаем такой результат:


Так же из логики работы компонента возникает небольшая проблема. Допустим мы не хотим чтобы на главной странице выводились эти крошки. Есть несколько способов, мы же ударим в лоб и напишем костыль.
Костыль
Создаём 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 модуль
// 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 у пользователя и у нашего сайта.
Пока что в корзине мы будем хранить только товары, поэтому прописываем очень простую логику для добавления и удаление товаров.
Создаём кнопку купить
<template> <div v-if="product"> <button v-if="!isProductAdded" :class="$style.buy" @click.prevent="buyClickHandler" > Купить </button> <a v-else :class="$style.added" href="#" @click.prevent="addedClickHandler" > Товар в корзине </a> </div> </template> <script> import { mapActions, mapState } from 'vuex' export default { props: { product: { type: Object, required: true } }, computed: { ...mapState({ products: state => state.cart.products }), isProductAdded () { return this.products.find(p => p.id === this.product.id) } }, methods: { ...mapActions({ addProduct: 'cart/addProduct', removeProduct: 'cart/removeProduct' }), buyClickHandler () { this.addProduct(this.product) }, addedClickHandler () { this.removeProduct(this.product.id) } } } </script> <style lang="scss" module> .buy { background-color: $basic-bg-color; /* Green */ border: none; color: #000; padding: 15px 32px; text-align: center; text-decoration: none; display: inline-block; font-size: 16px; &:hover { cursor: pointer; } } .added { text-decoration: none; border-bottom: 2px dotted; } </style>
Здесь есть несколько интересных моментов.
- в mapActions мы задаём путь указывая имя модуля cart/addProduct
- в mapState делаем то же самое, но через анонимную функцию, которая получает объект state state => state.cart.products
- храним флаг на случай если товар уже в корзине isProductAdded
- если товара нет в корзине, то будет кнопка купить, если уже есть, то выводим ссылку при нажатии на которую товар удаляется из корзины.
Подключаем этот компонент в карточку товара и на страницу товара.
В итоге получается такое вот чудо


Мы можем закрыть вкладку или открыть несколько вкладок, вся информация будет оставаться в браузере и синхронизировать по вкладкам (а также шифроваться в проде).
Итоги
Это вторая часть моего незамысловатого примера использования Nuxt.
В следующей статье я хочу реализовать возможность изменять количество товара в корзине, удалять их, просматривать все товары используя модальные окна.
А также создать страницу оформления заказа, блоки с рекомендуемыми товарами и добавить микроразметку.
Послесловие
Ребята, я стараюсь делать быстро как могу, но на каждую статью уходит минимум 6 часов времени. Мне бы хотелось лучше понять насколько такой формат интересный.
Буду рад выслушать ваши пожелания что прикрутить к магазину, добавить показать, а так же вопросы и не забудьте поддержать плюсиками, если вам зашло.
Спасибо за чтение!
ссылка на оригинал статьи https://habr.com/ru/post/491018/
Добавить комментарий