
Seperti yang dijanjikan, kami melanjutkan.
Di bagian ini:
- buat balok barang "Mereka juga membeli dengan produk ini" dan "Barang menarik"
- buat ikon keranjang dengan jumlah produk
- menghubungkan jendela modal dengan barang-barang di keranjang
- tulis ulang semua logika toko
Mari kita buat blok barang jenis: "Mereka juga membeli dengan produk ini"
Nuxt. . - , ?
AlsoBuyList asyncData, . , html , asyncData mounted ( ).
True SSR, asyncData . , , . , , , SSR.
.
Vuex :
index.js
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 }) {
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)
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
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
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 (!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 }) {
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) {
await sleep(50)
await commit('ADD_PRODUCT', productId)
await dispatch('setProductsListByIds')
},
async removeProduct ({ commit, dispatch }, productId) {
await sleep(50)
await commit('REMOVE_PRODUCT', productId)
await dispatch('setProductsListByIds')
},
async setProductQuantity ({ commit, dispatch }, { productId, qty }) {
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 ( ) .
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
:
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.
, , .
-.
, . ( ) .
, , , .
!