Ecomm tut
Setup and install Django
create repo
clone repo
create virtualenv: virtualenv env
source env/bin/activate
pip install django
pip install django-rest-framework
pip install django-cors-headers
pip install djoser
pip install pillow < -- image library
pip install stripe
django-admin startproject djackets_django
add to setting.py -> installed apps:
'rest_framework',
'rest_framework.authtoken',
'corsheaders',
'djoser',
configure cors (add to settings.py):
CORS_ALLOWED_ORIGINS = [
"http://localhost:8080",
]
configure middleware:
'corsheaders.middleware.CorsMiddleware',
#has to be above commonmiddleware
configure urls:
from django.urls import include
urlpatterns = [
...
path('api/v1/', include('djoser.urls')),
path('api/v1/', include('djoser.urls.authtoken')),
makemigrations - migrate - creatsuperuser - Done!
Install and setup vue
install vue on the pc if necessary:
npm install -g @vue/cli
start project:
vue create djackets_vue
Install packages:
cd djackets_vue
npm install axios <- make API calls
npm install bulma <- CSS framework
add FontAwesome to public/index.html:
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.15.2/css/all.min.css">
replace style in app.vue with:
@import '../node_modules/bulma';
and replace navbar according to vid @18:30
Create Django app
python manage.py startapp product
create models ...
Creating thumbnails of images:
To create thumbnails of images, you need to import the Pillow library, BytesIO and the File wrapper:
from django.core.files import File
from io import BytesIO
from PIL import Image
and add this function to the corresponding model:
def make_thumbnail(self, image, size=(300, 200)):
img = Image.open(image)
img.convert('RGB')
img.thumbnail(size)
thumb_io = BytesIO()
img.save(thumb_io, 'JPEG', quality=85)
thumbnail = File(thumb_io, name=image.name)
return thumbnail
and add media to settings.py:
MEDIA_URL = '/media/'
MEDIA_ROOT = BASE_DIR / 'media'
and to urls.py:
from django.conf import settings
from django.conf.urls.static import static
...
urlpatterns = [
...
] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
creating the API view
from rest_framework.views import APIView
from rest_framework.response import Response
from .models import Product
from .serializers import ProductSerializer
class LatestProductsList(APIView):
def get(self, request, format=None):
products = Product.objects.all()[:4]
serializer = ProductSerializer(products, many=True)
return Response(serializer.data)
and add that view to products/urls.py (keep links for apps seperated):
from django.urls import path, include
from product import views
urlpatterns = [
path('latest-products/' ,views.LatestProductsList.as_view())
]
and don't forget to add a link to the apps urls into the projects urls.py:
urlpatterns = [
...
path('api/v1/', include('product.urls')),
] ...
Create Vue frontpage
Create the page and import the data with axios. For that:
- Create an empty data array
- create a method with an axios get request to consume the api
- create a mounted lifecycle hook to import the data
<script>
import axios from 'axios' //<- import the module
export default {
name: 'Home',
data() {
return {
latestProducts: [] //empty Array
}
},
components: {
}
mounted() { //<-- Lifecycle hook calling the method when DOM is mounted
this.getLatestProducts()
},
methods: {
getLatestProducts() { //<- the axios get request
axios
.get('/api/v1/latest-products')
.then(response => {
this.latestProducts = response.data
})
.catch(error => {
console.log(error)
})
}
}
}
</script>
It won't work yet because the URL is unknown and axios needs to be imported into main.js:
import axios from 'axios'
axios.defaults.baseURL = 'http://127.0.0.1:8000'
createApp(App).use(store).use(router, axios).mount('#app')
Product detail page
Backend First, create the API endpoint for the product detail on the backend. views.py:
class ProductDetail(APIView):
def get_object(self, category_slug, product_slug):
try:
return Product.objects.filter(category__slug=category_slug).get(slug=product_slug)
except Product.DoesNotExist:
raise Http404
def get(self, category_slug, product_slug, format=None):
product = self.get_object(category_slug, product_slug)
serializer = ProductSerializer(product)
return Response(serializer.data)
Frontend add product.vue to vews folder. Check template here and add the products page to the view router -> index.js:
import Product from '../views/Product.vue'
const routes = [
...
{
path: '/:category_slug/:product_slug',
name: 'Product',
component: Product
}
]
Slugs
The way the links in the URL work is like this: They start in the backend with the models get_absolute_url method. This function creates a string out of the category and the name and transforms it into slugs and into a link. The link i ś then shown on the front page which loops through every unique object. If the link is clicked, the slug gets taken in from the router and is then passed on as an argument in the detail view. The detail view calls the detail backend API using the slugs to identify the item to the backend. The backend delivers further details to the product view with an axios call.
- Backend function get_absolute_url in model to create a url to the db entry made out of slugs
- Give a list of all objects to the main page in Vue, loop through this list and create a card with a link containing the frontend url out of the slugs of each object
- If a link is clicked, the product page is displayed. Here, the vue router gives the slugs from the url to the vue-view. The view method calls the backend api using the same slugs to create the link.
- The backend API gives the details of that single object to the vue-view which uses it to create the product detail page.
State management with vue x
VueX Create the store functionality in store/index.js:
import { createStore } from 'vuex'
export default createStore({
state: {
cart: {
items: [],
},
isAuthenticated: false,
token: '',
isLoading: false
},
mutations: {
initializeStore(state) {
if (localStorage.getItem('cart')) {
state.cart = JSON.parse(localStorage.getItem('cart'))
} else {
localStorage.setItem('cart', JSON.stringify(state.cart))
}
},
addToCart(state, item) {
const exists = state.cart.items.filter(i => i.product.id === item.product.id)
if (exists.length) {
exists[0].quantity = parseInt(exists[0].quantity) + parseInt(item.quantity)
} else {
state.cart.items.push(item)
}
localStorage.setItem('cart', JSON.stringify(state.cart))
}
},
actions: {
},
modules: {
}
})
and add the store to the App.vue:
<script>
export default{
data (){
return {
...
cart: {
items: [],
}
}
},
beforeCreate() {
this.$store.commit('initializeStore') //commit calls mutations defined in the store file
}
}
</script>
to make it possible to add an item to the cart, this needs to be implemented in the Product page. First, a button is added with the functionality:
...
<div class="control">
<a class="button is-dark" @click="addToCart()">Add to cart</a>
</div>
...
<script>
...
export default {
name: 'Product',
data() {
return {
product: {},
quantity: 1
}
},
...
methods: {
...
addToCart() {
if (isNaN(this.quantity) || this.quantity < 1) {
this.quantity = 1
}
const item = {
product: this.product,
quantity: this.quantity
}
this.$store.commit('addToCart', item)
}
}
}
</script>
and make sure the cart is initialised as variable in app.vue:
<script>
...
mounted() {
this.cart = this.$store.state.cart
},
computed: {
cartTotalLength() {
let totalLength = 0
for (let i = 0; i < this.cart.items.length; i++) {
totalLength += this.cart.items[i].quantity
}
return totalLength
}
}
}
</script>
To give the add to cart button some responsibility, we will use a "toast" popup that shows that an item has been added. Bulma has such an extension that can be installed with:
$ npm install bulma-toast
and add the toast functionality to the product view:
<script>
...
import { toast } from 'bulma-toast'
export default {
...
methods: {
...
toast({
message: 'The product was added to the cart',
type: 'is-success',
dismissible: true,
pauseOnHover: true,
duration: 2000,
position: 'bottom-right',
})
...
</script>
add a loading bar while FE communicates with BE server
in the store/index.js add a state isLoading and the functionailty to use it:
import { createStore } from 'vuex'
export default createStore({
state: {
...
isLoading: false
},
mutations: {
...
setIsLoading(state, status) {
state.isLoading = status
}
and set that status to true as soon as soon as the product page requests the details from the server (views/products.vue). To make sure that loading false is not called before the axios call is actually finished, make sure to set the function to async and add await to the axios call:
<script>
...
export default {
...
methods: {
async getProduct() {
this.$store.commit('setIsLoading', true)
...await axios call
this.$store.commit('setIsLoading', false)
},
and add the loading symbol to the app.vue:
<div class="is-loading-bar has-text-centered" :class="{'is-loading': $store.state.isLoading}">
<div class="lds-dual-ring"></div>
</div>
Create a category view
To create the category view we go the same way as for the detail view:
- Create view file Category.vue
- Within, create the template, the axios call etc.
- Create the entry in the router/index.js
Important: If we now change from Winter to Summer, the page will not change as they have a similar url ('category-slug') so the mounted lifecycle hook will not be called. To make navigation between similiarly named, dynamically created routes possible there is an option called "watch". This has to be added to the "Category.vue":
<script>
...
export default {
...
watch: {
$route(to, from) {
if (to.name === 'Category') {
this.getCategory()
}
}
},
Add search function
Backend
First, we add an Endpoint in our backend for the search functionality. In our views.py we add:
from django.db.models import Q #Improves query functionality (& and , | or )
from rest_framework.decorators import api_view #the decorator to only use this function with a POST request
....
@api_view(['POST'])
def search(request):
query = request.data.get('query', '')
if query:
products = Products.objects.filter(Q(name__icontains=query) | Q(description__icontains=query))
serializer = ProductSerializer(products, many=True)
return Response(serializer.data)
else:
return Response({'products': []})
and add the entry to the urls.py file:
urlpatterns = [
...
path('products/search', views.search),
...
]
Frontend
add a searchbar to the navbar in App.vue:
<form method="get" action="/search">
<div class="field has-addons">
<div class="control">
<input type="text" class="input" placeholder="What are you looking for?" name="query">
</div>
<div class="control">
<button class="button is-success">
<span class="icon">
<i class="fas fa-search"></i>
</span>
</button>
</div>
</div>
</form>
and create a landing page for that site that displays the results:
<template>
...
</template>
<script>
import axios from 'axios'
import ProductBox from '@/components/ProductBox.vue'
export default {
name: 'Search',
components: {
ProductBox
},
data() {
return {
products: [],
query: '',
}
},
mounted() {
document.title = 'Search | Djackets '
let uri = window.location.search.substring(1) // I don't get it, to look into
let params = new URLSearchParams(uri) //don't get it neither
if (params.get('query')) {
this.query = params.get('query')
this.performSearch()
}
},
methods: {
async performSearch () {
this.$store.commit('setIsLoading', true)
await axios
.post('/api/v1/products/search/', {'query': this.query})
.then(response => {
this.products = response.data
})
.catch(error => {
console.log(error)
})
this.$store.commit('setIsLoading', false)
}
}
}
</script>
add that page to the router:
import Search from '../views/Search.vue'
const routes = [
...
{
path: '/search',
name: 'Search',
component: Search
},
]
Done!
Add a cart
Kind of the same thing by now. Add a view and a router entry. In this instance, we want to display each item in a particular way so it makes sense to create a component for this too. The most important parts are the functionality sections of the site and the component. Cart.vue:
<template>
<div class="page-cart">
<div class="columns is-multiline">
<div class="column is-12">
<h1 class="title">Cart</h1>
</div>
<div class="column is-12 box">
<table class="table is-fullwidth" v-if="cartTotalLength">
<thead>
<tr>
<th>Product</th>
<th>Price</th>
<th>Quantity</th>
<th>Total</th>
<th></th>
</tr>
</thead>
<tbody>
<CartItem
v-for="item in cart.items"
v-bind:key="item.product.id"
v-bind:initialItem="item"
v-on:removeFromCart="removeFromCart" />
</tbody>
</table>
<p v-else>You don't have any products in your cart...</p>
</div>
<div class="column is-12 box">
<h2 class="subtitle">Summary</h2>
<strong>${{ cartTotalPrice.toFixed(2) }}</strong>, {{ cartTotalLength }} items
<hr>
<router-link to="/cart/checkout" class="button is-dark">Proceed to checkout</router-link>
</div>
</div>
</div>
</template>
<script>
import axios from 'axios'
import CartItem from '@/components/CartItem.vue'
export default {
name: 'Cart',
components: {
CartItem
},
data() {
return {
cart: {
items: []
}
}
},
mounted() {
this.cart = this.$store.state.cart
},
methods: {
removeFromCart(item) {
this.cart.items = this.cart.items.filter(i => i.product.id !== item.product.id)
}
},
computed: {
cartTotalLength() {
return this.cart.items.reduce((acc, curVal) => {
return acc += curVal.quantity
}, 0)
},
cartTotalPrice() {
return this.cart.items.reduce((acc, curVal) => {
return acc += curVal.product.price * curVal.quantity
}, 0)
},
}
}
</script>
and the component CartItem.vue:
<template>
<tr>
<td><router-link :to="item.product.get_absolute_url">{{ item.product.name }}</router-link></td>
<td>${{ item.product.price }}</td>
<td>
{{ item.quantity }}
<a @click="decrementQuantity(item)">-</a>
<a @click="incrementQuantity(item)">+</a>
</td>
<td>${{ getItemTotal(item).toFixed(2) }}</td>
<td><button class="delete" @click="removeFromCart(item)"></button></td>
</tr>
</template>
<script>
export default {
name: 'CartItem',
props: {
initialItem: Object
},
data() {
return {
item: this.initialItem
}
},
methods: {
getItemTotal(item) {
return item.quantity * item.product.price
},
decrementQuantity(item) {
item.quantity -= 1
if (item.quantity === 0) {
this.$emit('removeFromCart', item)
}
this.updateCart()
},
incrementQuantity(item) {
item.quantity += 1
this.updateCart()
},
updateCart() {
localStorage.setItem('cart', JSON.stringify(this.$store.state.cart))
},
removeFromCart(item) {
this.$emit('removeFromCart', item)
this.updateCart()
},
},
}
</script>
Done!
Signup
We only need to do the frontend part of this. The BE implementation is already done by importing djoser. Create a sign-up page on the frontend. This one has forms with simple validation and a connection to the backend:
<template>
<div class="page-sign-up">
<div class="columns">
<div class="column is-4 is-offset-4">
<h1 class="title">Sign up</h1>
<form @submit.prevent="submitForm">
<div class="field">
<label>Username</label>
<div class="control">
<input type="text" class="input" v-model="username">
</div>
</div>
<div class="field">
<label>Password</label>
<div class="control">
<input type="password" class="input" v-model="password">
</div>
</div>
<div class="field">
<label>Repeat password</label>
<div class="control">
<input type="password" class="input" v-model="password2">
</div>
</div>
<div class="notification is-danger" v-if="errors.length">
<p v-for="error in errors" v-bind:key="error">{{ error }}</p>
</div>
<div class="field">
<div class="control">
<button class="button is-dark">Sign up</button>
</div>
</div>
<hr>
Or <router-link to="/log-in">click here</router-link> to log in!
</form>
</div>
</div>
</div>
</template>
<script>
import axios from 'axios'
import { toast } from 'bulma-toast'
export default {
name: 'SignUp',
data() {
return {
username: '',
password: '',
password2: '',
errors: []
}
},
methods: {
submitForm() {
this.errors = []
if (this.username === '') {
this.errors.push('The username is missing')
}
if (this.password === '') {
this.errors.push('The password is too short')
}
if (this.password !== this.password2) {
this.errors.push('The passwords don\'t match')
}
if (!this.errors.length) {
const formData = {
username: this.username,
password: this.password
}
axios
.post("/api/v1/users/", formData)
.then(response => {
toast({
message: 'Account created, please log in!',
type: 'is-success',
dismissible: true,
pauseOnHover: true,
duration: 2000,
position: 'bottom-right',
})
this.$router.push('/log-in')
})
.catch(error => {
if (error.response) {
for (const property in error.response.data) {
this.errors.push(`${property}: ${error.response.data[property]}`)
}
console.log(JSON.stringify(error.response.data))
} else if (error.message) {
this.errors.push('Something went wrong. Please try again')
console.log(JSON.stringify(error))
}
})
}
}
}
}
</script>
the log in page will be almost identical. For the state management on the fron end side (logged in , logged out) we have to add an option to initializeStore to set the isAuthenticated state in the store/index.js file that checks if a session token is present. Also, we need a function to set and remove the token. store/index.js:
mutations: {
initializeStore(state) {
...
if (localStorage.getItem('token')) {
state.token = localStorage.getItem('token')
state.isAuthenticated = true
} else {
state.token = ''
state.isAuthenticated = false
}
},
...
setToken(state, token) {
state.token = token
state.isAuthenticated = true
},
removeToken(state) {
state.token = ''
state.isAuthenticated = false
}
to make the token available to our axios calls in our views, we add this bit to our App.vue file:
const token = this.$store.state.token
if (token) {
axios.defaults.headers.common['Authorization'] = 'Token ' + token
} else {
axios.defaults.headers.common['Authorization'] = ''
}
Done. BE implementation is done through djoser
Myaccount
The myaccount page is a page where you can logout or see your order history. The page looks like this:
<template>
<div class="page-my-account">
<div class="columns is-multiline">
<div class="column is-12">
<h1 class="title">My account</h1>
</div>
<div class="column is-12">
<button @click="logout()" class="button is-danger">Log out</button>
</div>
<hr>
<div class="column is-12">
<h2 class="subtitle">My orders</h2>
<OrderSummary
v-for="order in orders"
v-bind:key="order.id"
v-bind:order="order" />
</div>
</div>
</div>
</template>
<script>
import axios from 'axios'
import OrderSummary from '@/components/OrderSummary.vue'
export default {
name: 'MyAccount',
components: {
OrderSummary
},
data() {
return {
orders: []
}
},
mounted() {
document.title = 'My account | Djackets'
this.getMyOrders()
},
methods: {
logout() {
axios.defaults.headers.common["Authorization"] = ""
localStorage.removeItem("token")
localStorage.removeItem("username")
localStorage.removeItem("userid")
this.$store.commit('removeToken')
this.$router.push('/')
},
async getMyOrders() {
this.$store.commit('setIsLoading', true)
await axios
.get('/api/v1/orders/')
.then(response => {
this.orders = response.data
})
.catch(error => {
console.log(error)
})
this.$store.commit('setIsLoading', false)
}
}
}
</script>
the important part here is the router implementation:
...
import store from '../store'
...
const routes = [
...
{
path: '/my-account',
name: 'MyAccount',
component: MyAccount,
meta: {
requireLogin: true
}
},
]
const router = createRouter({
history: createWebHistory(process.env.BASE_URL),
routes
})
router.beforeEach((to, from, next) => {
if (to.matched.some(record => record.meta.requireLogin) && !store.state.isAuthenticated) {// if the page that is about to be accessed has requireLogin true and isAuthenticated is false ...
next({ name: 'LogIn', query: { to: to.path } });//... you will be forwarded to the login page ...
} else {
next()//... if not you will be forwarded to the page that had been requested
}
})
Add stripe payment
Setup a stripe account and add the api key to the BE. settings.py. Also, create a new app to manage payments (python manage.py startapp order):
STRIPE_SECRET_KEY = 'IANGOIEANGOIENGOIEANGOIIN'
...
INSTALLED_APPS = {
....
'order',
}
on the frontend side, don't forget to add the stripe import to the public/index.html file:
<script src="https://js.stripe.com/v3/"></script>
use the secret key for the backend and the publishable key for the frontend