Vue online store with products rendered from a JSON file. Using Vue-router, vuex, and axios.
A Pen by Patrick Hurley on CodePen.
<!-- Component: product --> | |
<template id="product"> | |
<div class="product"> | |
<div class="product-image"> | |
<img :src="`http://www.patrickhurley.co.uk/assets/img/${img}.png`" alt=""/> | |
</div> | |
<div class="product-info"> | |
<h3> | |
<router-link :to="{ name: 'product-detail', params: { product: slashedName } }">{{ name }}</router-link> | |
</h3> | |
<p>{{ formatPrice }}</p> | |
<blockquote v-show="itemCount(itemIndex) > 0">{{ itemCount(itemIndex) + ' in cart' }}</blockquote> | |
<button class="btn" @click="addToCart(product)">Add to Cart</button> | |
</div> | |
</template> | |
<!-- /product --> | |
<!-- Component: department --> | |
<template id="department"> | |
<div class="content"> | |
<section v-if="errored"> | |
<p>Hmmm. Something has gone wrong.</p> | |
</section> | |
<section v-if="!errored"> | |
<p v-show="loading">Loading... <i class="fas fa-spinner fa-spin"></i></p> | |
<Product v-for="(product, index) in filteredProducts" :key="index" v-bind="product"/> | |
</section> | |
</div> | |
</template> | |
<!-- /department --> | |
<!-- Component: product-detail --> | |
<template id="product-detail"> | |
<div v-if="product" class="product-detail"> | |
<h2>{{ product.name }}</h2> | |
<img :src="`http://www.patrickhurley.co.uk/assets/img/${product.img}.png`" alt=""/> | |
<p>{{ lorem }}</p> | |
<router-link class="btn red lighten-1" :to="{ name: 'department', params: { department: product.department.toLowerCase() }}"><i class="fas fa-arrow-left"></i> Back to {{ product.department }}</router-link> | |
<button class="btn" @click="addToCart(productPayload)">Add to cart</button> | |
<blockquote v-show="itemCount(itemIndex) > 0">{{ itemCount(itemIndex) + ' in cart' }}</blockquote> | |
</div> | |
</template> | |
<!-- /product-detail --> | |
<!-- App --> | |
<div v-cloak id="app" class="outer"> | |
<div class="nav"> | |
<div class="logo"> | |
<img src="https://vuejs.org/images/logo.png" class="vue-logo-v"/><h1><router-link class="no-active" to="/">uemart</router-link></h1> | |
</div> | |
<div class="shopping-status"> | |
<a @click="showModal = true">Cart: ({{ cartCount }})</a> | |
</div> | |
</div> | |
<section class="table full"> | |
<aside> | |
<div class="menu"> | |
<h2>Department</h2> | |
<div v-if="errored"> | |
<p>Something went wrong</p> | |
</div> | |
<div v-if="!errored"> | |
<p v-show="loading">Loading... <i class="fas fa-spinner fa-spin"></i></p> | |
<ul> | |
<li v-for="(department, index) in departments" :key="index"> | |
<router-link :to="{ name: 'department', params: { department: department }}">{{ department }}</router-link> | |
</li> | |
</ul> | |
</div> | |
</div> | |
</aside> | |
<main> | |
<transition name="medium-fade" @after-leave="afterLeave"> | |
<router-view :key="$route.path"></router-view> | |
</transition> | |
</main> | |
</section> | |
<!-- modal --> | |
<transition name="fast-fade"> | |
<div v-show="showModal" class="modal-backdrop"> | |
<div class="modalouter"> | |
<div class="modal-cell"> | |
<div class="modal-content"> | |
<h2>Shopping Cart</h2> | |
<hr> | |
<div v-if="cartItems.length != 0"> | |
<div class="cart-container"> | |
<div class="cart-product" v-for="(item, index) in cartItems" :key="index"> | |
<div>{{ item.name | capitalise }}</div> | |
<div>{{ itemTotal(index) | currency }}</div> | |
<div> | |
<button @click="changeQuantity(index,false)">-</button> | |
<input type="text" class="browser-default" :value="item.quantity" disabled> | |
<button @click="changeQuantity(index,true)">+</button> | |
</div> | |
<div><i @click="removeAllItems(item, index)" :class="item.isDeleting ? 'fas fa-spinner fa-spin' : 'fas fa-trash-alt'"></i></div> | |
</div> | |
</div> | |
<p class="shopping-cart_total">Total: {{ cartTotal | currency }}</p> | |
</div> | |
<div v-if="cartItems.length == 0"> | |
<p>Your shopping cart is empty</p> | |
</div> | |
<button class="btn close-modal" @click="showModal = false">Close cart</button> | |
</div> | |
</div> | |
</div> | |
</div> | |
</transition> | |
<!--modal--> | |
</div> |
// Vuex Store | |
const store = new Vuex.Store({ | |
state: { | |
cartItems: [], | |
products: [], | |
loading: true, | |
errored: false | |
}, | |
mutations: { | |
ADD_NEW_ITEM: (state, item) => { | |
item.isDeleting = false; | |
item = { ...item, | |
quantity: 1 | |
} | |
state.cartItems = [...state.cartItems, item]; | |
}, | |
UPDATE_CART: (state, item) => { | |
let findIndex = state.cartItems.findIndex(x => x.name == item.name); | |
state.cartItems[findIndex].quantity++; | |
}, | |
CHANGE_QUANTITY: (state, { | |
index, | |
increase | |
}) => { | |
if (increase) { | |
state.cartItems[index].quantity++; | |
} else { | |
if (state.cartItems[index].quantity == 1) { | |
state.cartItems.splice(index, 1); | |
} else { | |
state.cartItems[index].quantity--; | |
} | |
} | |
}, | |
REMOVE_ALL: (state, index) => { | |
state.cartItems.splice(index, 1); | |
}, | |
LOAD_PRODUCTS: (state, products) => { | |
state.products = products | |
state.loading = false | |
}, | |
ERRORED: (state, error) => { | |
state.errored = true; | |
console.error(error); | |
} | |
}, | |
actions: { | |
removeAll: ({ | |
commit | |
}, index) => { | |
return new Promise((resolve) => { | |
setTimeout(() => { | |
commit('REMOVE_ALL', index) | |
resolve() | |
}, 700) | |
}) | |
}, | |
retrieveProducts: ({ | |
commit | |
}) => { | |
// eslint-disable-next-line no-undef | |
axios | |
.get('https://api.jsonbin.io/b/5c6eadd27bded36fef1b653e/1') | |
.then(response => { | |
commit('LOAD_PRODUCTS', response.data.products) | |
}) | |
.catch(error => { | |
commit('ERRORED', error) | |
}) | |
}, | |
addToCart: ({ | |
commit, | |
state | |
}, product) => { | |
let found = state.cartItems.some((el) => { | |
return el.name === product.name | |
}); | |
if (!found) { | |
commit('ADD_NEW_ITEM', product) | |
} else { | |
commit('UPDATE_CART', product) | |
} | |
} | |
}, | |
getters: { | |
cartCount: state => { | |
if (state.cartItems.length === 0) { | |
return 'empty' | |
} else { | |
return state.cartItems.reduce((a, b) => a + b.quantity, 0); | |
} | |
}, | |
cartTotal: state => { | |
return (state.cartItems.reduce((a, b) => a + (b.price * b.quantity), 0)).toFixed(2); | |
}, | |
itemCount: (state) => (index) => { | |
if (index >= 0) { | |
return state.cartItems[index].quantity | |
} | |
}, | |
itemTotal: (state) => (index) => { | |
if (state.cartItems[index]) { | |
return (state.cartItems[index].price * state.cartItems[index].quantity); | |
} | |
} | |
} | |
}) | |
// Vue filter | |
Vue.filter('capitalise', function (val) { | |
return val.toUpperCase(); | |
}); | |
// Vue component: homepage | |
const Homepage = { | |
render: function (createElement) { | |
return createElement( | |
'div', { | |
class: 'content' | |
}, [ | |
createElement('div', { | |
attrs: { | |
style: 'float:left' | |
} | |
}, [ | |
createElement('h2', 'Welcome to the store'), | |
createElement('p', 'Select a department to get started') | |
]), createElement('img', { | |
class: 'vue-logo', | |
attrs: { | |
src: 'https://vuejs.org/images/logo.png' | |
} | |
}) | |
]) | |
} | |
} | |
// Vue component: product | |
const Product = { | |
template: '#product', | |
data() { | |
return { | |
product: { | |
'name': this.name, | |
'price': this.price | |
} | |
} | |
}, | |
props: { | |
name: String, | |
img: String, | |
price: Number | |
}, | |
methods: { | |
...Vuex.mapMutations([ | |
'ADD_NEW_ITEM', | |
'UPDATE_CART' | |
]), | |
...Vuex.mapActions([ | |
'addToCart' | |
]) | |
}, | |
computed: { | |
...Vuex.mapState([ | |
'cartItems' | |
]), | |
...Vuex.mapGetters([ | |
'itemCount' | |
]), | |
formatPrice() { | |
return this.price.toFixed(2); | |
}, | |
itemIndex() { | |
return this.cartItems.findIndex(x => x.name == this.name) | |
}, | |
slashedName() { | |
return this.name.replace(/\s+/g, '-').toLowerCase(); | |
} | |
} | |
} | |
// Vue component: department | |
const Department = { | |
template: '#department', | |
components: { | |
'product': Product | |
}, | |
computed: { | |
...Vuex.mapState([ | |
'products', | |
'loading', | |
'errored' | |
]), | |
filteredProducts() { | |
return this.products.filter(x => x.department.toLowerCase() == this.$route.params.department) | |
} | |
} | |
} | |
// Vue component: product-detail | |
const ProductDetail = { | |
template: '#product-detail', | |
data() { | |
return { | |
lorem: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Donec posuere tortor ac sapien iaculis, vitae iaculis nunc iaculis. Mauris justo nisi, tempor venenatis felis vel, elementum venenatis mi. Morbi in dolor vehicula, sollicitudin ante non, eleifend tellus. Nunc mollis tortor quis sapien aliquet porttitor. Duis eu turpis vel sapien tristique sodales. Ut quis risus sed dui sagittis ultricies vel eu felis. Donec in cursus tortor, vitae vehicula nisl.' | |
} | |
}, | |
methods: { | |
...Vuex.mapActions([ | |
'addToCart' | |
]) | |
}, | |
computed: { | |
...Vuex.mapState([ | |
'products', | |
'cartItems' | |
]), | |
...Vuex.mapGetters([ | |
'itemCount' | |
]), | |
product() { | |
let findProduct = this.products.filter(x => x.name == this.formattedProduct) | |
return findProduct[0]; | |
}, | |
formattedProduct() { | |
let removeSlash = this.$route.params.product.replace(/-/g, ' ') | |
return removeSlash.replace(/\w\S*/g, function (txt) { | |
return txt.charAt(0).toUpperCase() + txt.substr(1).toLowerCase(); | |
}); | |
}, | |
productPayload() { | |
return { | |
'name': this.product.name, | |
'price': this.product.price | |
} | |
}, | |
itemIndex() { | |
return this.cartItems.findIndex(x => x.name == this.product.name) | |
}, | |
} | |
} | |
// Vue Router | |
Vue.use(VueRouter) | |
const router = new VueRouter({ | |
routes: [{ | |
path: '/', | |
name: 'homepage', | |
component: Homepage | |
}, | |
{ | |
path: '/department/:department', | |
name: 'department', | |
component: Department, | |
props: true | |
}, | |
{ | |
path: '/product/:product', | |
name: 'product-detail', | |
component: ProductDetail, | |
props: true | |
} | |
] | |
}) | |
// Vue instance | |
new Vue({ | |
el: '#app', | |
router, | |
store, | |
name: 'app', | |
data() { | |
return { | |
basketIsShown: false, | |
showModal: false, | |
item: '' | |
} | |
}, | |
filters: { | |
currency: (price) => { | |
return parseFloat(price).toFixed(2); | |
} | |
}, | |
methods: { | |
...Vuex.mapMutations([ | |
'CHANGE_QUANTITY' | |
]), | |
...Vuex.mapActions([ | |
'removeAll', | |
'retrieveProducts' | |
]), | |
showBasket: function () { | |
this.basketIsShown = !this.basketIsShown; | |
}, | |
changeQuantity: function (index, increase) { | |
this.CHANGE_QUANTITY({ | |
index, | |
increase | |
}) | |
}, | |
removeAllItems: function (item, index) { | |
this.$store.state.cartItems[index].isDeleting = true; | |
this.removeAll(index); | |
}, | |
afterLeave() { | |
window.scroll(0, 0) | |
} | |
}, | |
computed: { | |
...Vuex.mapState([ | |
'cartItems', | |
'products', | |
'loading', | |
'errored' | |
]), | |
...Vuex.mapGetters([ | |
'cartCount', | |
'cartTotal', | |
'itemTotal' | |
]), | |
departments() { | |
let allDepartments = this.products.map(x => x.department.toLowerCase()) | |
return [...new Set(allDepartments)] | |
} | |
}, | |
mounted() { | |
this.retrieveProducts() | |
} | |
}) |
<script src="https://cdnjs.cloudflare.com/ajax/libs/vuex/3.1.0/vuex.min.js"></script> | |
<script src="https://cdnjs.cloudflare.com/ajax/libs/vue-router/3.0.2/vue-router.min.js"></script> | |
<script src="https://unpkg.com/axios/dist/axios.min.js"></script> | |
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/vue.js"></script> |
// Hide app until loaded | |
[v-cloak] { display: none; } | |
// Base | |
html { | |
font-family: 'Open Sans', sans-serif; | |
} | |
ul, li, p, h1, h2, h3 { | |
margin: 0; | |
padding: 0; | |
} | |
ul { | |
list-style: none; | |
} | |
p { | |
font-size: 18px; | |
} | |
a { | |
color: black; | |
} | |
blockquote { | |
margin: 0; | |
} | |
// Colours | |
$bg-color: #f3f3f3; | |
$accent-color: #dbfff3; | |
// Grid | |
$screen-xs-max: 540px; | |
$screen-sm-min: 541px; | |
$screen-sm-max: 767px; | |
$screen-md-min: 768px; | |
$screen-md-max: 920px; | |
$screen-lg-min: 921px; | |
$screen-lg-max: 1200px; | |
// Layout | |
.table { | |
display: table; | |
} | |
.full { | |
width: 100%; | |
} | |
.outer { | |
max-width: 1100px; | |
margin: 0 auto; | |
@media only screen and (min-width: $screen-sm-min){ | |
padding: 0 15px; | |
} | |
} | |
.inner { | |
padding: 20px 30px; | |
@media only screen and (max-width: $screen-sm-max){ | |
padding: 20px; | |
} | |
} | |
.nav { | |
overflow: auto; | |
background: $accent-color; | |
border-radius: 10px; | |
margin-top: 30px; | |
padding: 41px 30px; | |
@media only screen and (max-width: $screen-sm-max){ | |
margin-top: 0px; | |
} | |
} | |
.logo { | |
font-family: 'Bungee'; | |
@media only screen and (min-width: $screen-md-min){ | |
float: left; | |
} | |
a { | |
color: black; | |
font-size: 50px; | |
@media only screen and (max-width: $screen-sm-max){ | |
font-size: 60px; | |
} | |
} | |
h1 { | |
display: inline-block; | |
} | |
} | |
.vue-logo { | |
width: 150px; | |
float: right; | |
@media only screen and (max-width: $screen-xs-max){ | |
display: none; | |
} | |
} | |
.vue-logo-v { | |
width: 42px; | |
float: left; | |
margin-top: 18px; | |
@media only screen and (max-width: $screen-lg-max){ | |
margin-top: 15px; | |
} | |
@media only screen and (max-width: $screen-sm-max){ | |
width: 50px; | |
margin-top:7px; | |
} | |
} | |
// Shopping cart | |
.shopping-status { | |
@media only screen and (min-width: $screen-md-min){ | |
float: right; | |
padding-top: 22px; | |
} | |
@media only screen and (max-width: $screen-sm-max){ | |
margin-top: 5px; | |
} | |
a { | |
cursor: pointer; | |
color: black; | |
font-size: 18px; | |
} | |
a:before{ | |
font-family: 'Font Awesome 5 Free'; | |
content: '\f07a'; | |
padding-right: 10px; | |
font-weight: 700; | |
} | |
} | |
.shopping-cart { | |
background: #bebebe; | |
border-radius: 10px; | |
margin-top: 20px; | |
color: white; | |
padding: 20px 30px; | |
font-weight: 900; | |
li { | |
font-size: 20px; | |
} | |
&_total { | |
border-top: 2px solid #eeeeee; | |
margin-top: 20px; | |
padding-top: 10px; | |
font-size: 25px; | |
} | |
} | |
.cart-container { | |
@media only screen and (min-width: $screen-sm-min){ | |
display: table; | |
} | |
} | |
.cart-product { | |
@media only screen and (max-width: $screen-xs-max) { | |
text-align: center; | |
margin-bottom: 15px; | |
padding-bottom: 15px; | |
width: 100%; | |
border-bottom: 1px solid #eeeeee; | |
div { | |
margin-bottom: 10px; | |
} | |
.fa-trash-alt { | |
width: 100%; | |
background: #EF5350; | |
padding: 10px; | |
text-align: center; | |
border-radius: 5px; | |
&:before { | |
content: 'Remove'; | |
font-family: "Open Sans"; | |
color: white; | |
} | |
} | |
} | |
@media only screen and (min-width: $screen-sm-min){ | |
display: table-row; | |
div { | |
display: table-cell; | |
vertical-align: middle; | |
} | |
div:nth-of-type(1) { | |
min-width: 220px; | |
} | |
div:nth-of-type(2) { | |
width: 100px; | |
} | |
div:nth-of-type(3) { | |
width: 150px; | |
} | |
} | |
button { | |
background: transparent; | |
border: none; | |
font-size: 17px; | |
padding: 5px; | |
width: 30px; | |
cursor: pointer; | |
&:focus { | |
outline: none; | |
} | |
} | |
input { | |
width: 40px; | |
text-align: center; | |
padding: 5px; | |
} | |
i { | |
cursor: pointer; | |
} | |
} | |
section.table { | |
margin-top: 20px; | |
} | |
aside { | |
display: table-cell; | |
width: 300px; | |
@media only screen and (max-width: $screen-sm-max){ | |
display: block; | |
width: 100%; | |
} | |
@media only screen and (max-width: $screen-md-max){ | |
width: 30%; | |
} | |
vertical-align: top; | |
.menu { | |
border: 3px solid #eeeeee; | |
@media only screen and (max-width: $screen-sm-max){ | |
border: none; | |
} | |
border-radius: 10px; | |
padding: 20px 30px 30px; | |
} | |
h2 { | |
text-transform: uppercase; | |
font-size: 20px; | |
letter-spacing: 2px; | |
} | |
ul { | |
margin-top: 15px; | |
} | |
li { | |
margin-top: 10px; | |
} | |
a { | |
color: #45dd9e; | |
text-decoration: none; | |
letter-spacing: 3px; | |
font-size: 16px; | |
text-transform: uppercase; | |
&:hover { | |
text-decoration: underline; | |
} | |
} | |
} | |
main { | |
display: table-cell; | |
padding-left: 30px; | |
height: 1em; | |
@media only screen and (max-width: $screen-sm-max){ | |
display: block; | |
width: 100%; | |
padding-left: 0px; | |
} | |
} | |
.router-link-active:not(.no-active) { | |
position: relative; | |
&:before { | |
position: absolute; | |
font-family: 'Font Awesome 5 Free'; | |
content: '\f054'; | |
font-weight: 900; | |
font-size: 12px; | |
left: -15px; | |
top: 4px; | |
} | |
} | |
// Modal | |
.modal-backdrop { | |
position: fixed; | |
top: 0; | |
bottom: 0; | |
left: 0; | |
right: 0; | |
background-color: rgba(0, 0, 0, 0.1); | |
display: flex; | |
justify-content: center; | |
align-items: center; | |
@media only screen and (max-width: $screen-xs-max){ | |
display: block; | |
overflow: auto; | |
} | |
} | |
.modalouter { | |
@media only screen and (min-width: $screen-sm-min){ | |
display: table; | |
height: 80vh; | |
} | |
background: #FFFFFF; | |
border-radius: 10px; | |
border: 1px solid rgb(201, 201, 201); | |
@media only screen and (min-width: $screen-sm-min) and (max-width: $screen-sm-max){ | |
width: 95vw; | |
} | |
@media only screen and (min-width: $screen-md-min){ | |
width: 80vw; | |
} | |
} | |
.modal-cell { | |
@media only screen and (min-width: $screen-sm-min){ | |
display: table-cell; | |
vertical-align: middle; | |
} | |
@media only screen and (max-width: $screen-sm-max){ | |
padding: 20px; | |
} | |
} | |
.modal-content { | |
margin: 0 auto; | |
@media only screen and (min-width: $screen-md-min){ | |
width: 500px; | |
} | |
h2 { | |
margin-bottom: 30px; | |
@media only screen and (max-width: $screen-xs-max){ | |
font-size: 20px; | |
text-align: center; | |
margin-bottom: 15px; | |
} | |
@media only screen and (min-width: $screen-sm-min) and (max-width: $screen-sm-max){ | |
font-size: 30px; | |
} | |
} | |
hr { | |
margin-bottom: 20px; | |
} | |
.close-modal { | |
margin-top: 30px; | |
@media only screen and (max-width: $screen-xs-max){ | |
width: 100%; | |
} | |
} | |
} | |
// Transition | |
.medium-fade-enter-active, .v-leave-active { | |
transition: opacity 2s | |
} | |
.medium-fade-enter, .medium-fade-leave-to { | |
opacity: 0; | |
} | |
.medium-fade-leave, .medium-fade-enter-to { | |
opacity: 1; | |
} | |
.fast-fade-enter-active, .fast-fade-leave-active { | |
transition: opacity 0.5s | |
} | |
.fast-fade-enter, .fast-fade-leave-to { | |
opacity: 0; | |
} | |
.fast-fade-leave, .fast-fade-enter-to { | |
opacity: 1; | |
} | |
// Product Component | |
$color-button: #d4d4d4; | |
.product { | |
display: table; | |
width: 100%; | |
height: 250px; | |
@media only screen and (max-width: $screen-md-max){ | |
height: 180px; | |
} | |
margin-bottom: 30px; | |
background: #f5f5f5; | |
border-radius: 10px; | |
padding: 10px; | |
.product-image { | |
@media only screen and (min-width: $screen-md-min){ | |
display: table-cell; | |
width: 200px; | |
padding: 20px; | |
} | |
@media only screen and (min-width: $screen-lg-min) { | |
width: 350px; | |
} | |
padding: 10px; | |
img { | |
width: 100%; | |
} | |
} | |
.product-info { | |
@media only screen and (min-width: $screen-md-min){ | |
display: table-cell; | |
} | |
vertical-align: top; | |
padding: 20px 10px; | |
@media only screen and (max-width: $screen-md-max){ | |
h3 { | |
font-size: 30px; | |
} | |
} | |
a:hover { | |
text-decoration: underline; | |
text-decoration-color: #b7ebd9; | |
} | |
} | |
blockquote { | |
margin: 20px 0 10px; | |
} | |
.btn { | |
margin-top: 15px; | |
user-select: none; | |
-webkit-user-select: none; | |
-ms-user-select: none; | |
-webkit-touch-callout: none; | |
-o-user-select: none; | |
-moz-user-select: none; | |
} | |
} | |
// Homepage component | |
.content { | |
background: #f5f5f5; | |
padding: 20px 30px 50px; | |
height: 100%; | |
@media only screen and (max-width: $screen-sm-max){ | |
overflow: auto; | |
height: auto; | |
} | |
} | |
h2 { | |
font-size: 25px; | |
font-weight: 900; | |
margin: 10px 0 20px; | |
} | |
// ProductDetail component | |
.product-detail { | |
width: 80%; | |
@media only screen and (max-width: $screen-sm-max){ | |
width: 100%; | |
margin: 0 auto; | |
padding-left: 25px; | |
padding-right: 25px; | |
} | |
margin-bottom: 50px; | |
h2 { | |
font-size: 40px; | |
@media only screen and (max-width: $screen-sm-max){ | |
font-size: 35px; | |
} | |
} | |
img { | |
width: 100%; | |
margin: 20px 0; | |
} | |
i { | |
font-size: 14px; | |
} | |
p { | |
margin-bottom: 20px; | |
} | |
.btn { | |
@media only screen and (max-width: $screen-sm-max){ | |
display: block; | |
width: 100%; | |
} | |
} | |
button { | |
@media only screen and (max-width: $screen-sm-max){ | |
margin-top: 20px; | |
} | |
@media only screen and (min-width: $screen-md-min){ | |
margin-left: 30px; | |
} | |
} | |
blockquote { | |
display: inline-block; | |
margin-left: 30px; | |
@media only screen and (max-width: $screen-md-max){ | |
display: block; | |
margin: 20px 0 | |
} | |
} | |
} |
<link href="https://cdnjs.cloudflare.com/ajax/libs/materialize/1.0.0/css/materialize.min.css" rel="stylesheet" /> | |
<link href="https://fonts.googleapis.com/css?family=Bungee" rel="stylesheet" /> | |
<link href="https://use.fontawesome.com/releases/v5.7.2/css/all.css" rel="stylesheet" /> |
Vue online store with products rendered from a JSON file. Using Vue-router, vuex, and axios.
A Pen by Patrick Hurley on CodePen.