This is the final part in this series, in this part we will add a simple module in the real-estate-app which is the favorite properties in the user dashboard.
Our work in this module include client side only, as no backend Api is needed in this part. To store the favorite properties we will consider using client side storage like browser localStorage.
For this purpose i will create a new simple composable file “useFavorites.js”
resources/js/composables/useFavorites.js
import {useToast} from "vue-toastification"; export const useFavorites = () => { const toast = useToast(); function writeToStorage(favs) { window.localStorage.setItem("favorites", JSON.stringify(favs)); } function getFavoritesFromStorage() { return window.localStorage.getItem("favorites") ? JSON.parse(window.localStorage.getItem("favorites")) : []; } function addToFavorites(pid) { let favs = getFavoritesFromStorage(); if(favs.find(i => parseInt(i) === parseInt(pid)) === undefined) { favs = [...favs, pid]; writeToStorage(favs); } toast.success("Property added to favorites"); return favs; } function removeFromFavorites(pid) { let favs = getFavoritesFromStorage(); if(favs.find(i => parseInt(i) === parseInt(pid)) !== undefined) { favs = favs.filter(i => parseInt(i) !== parseInt(pid)); writeToStorage(favs); } toast.success("Property removed from favorites"); return favs; } function isPropertyInFavorites(pid) { let favs = getFavoritesFromStorage(); return favs.find(i => parseInt(i) === parseInt(pid)) !== undefined; } function searchFavorites(cb) { window.axios.get('/api/search', {params: { ids: getFavoritesFromStorage().join(",") } }).then(response => { cb(response.data.properties.data); }); } return { addToFavorites, removeFromFavorites, isPropertyInFavorites, getFavoritesFromStorage, searchFavorites } }
This composable function return some helper methods to deal with storing and reading favorites from and to storage:
- writeToStorage(): store the favorite ids in local storage.
- getFavoritesFromStorage(): returns the favorites ids from storage.
- addToFavorites(): store specific property id to storage.
- removeFromFavorites(): Remove specific property id from storage.
- isPropertyInFavorites(): Check whether property id in storage.
- searchFavorites(): retrieves the associated properties by using favorites property ids.
There are multiple places we will use this composable which are PropertySingle.vue and PropertyForm.vue and user/Favorites.vue.
Let’s start with PropertySingle.vue:
resources/js/components/pages/PropertySingle.js
<template> <div class="fa-3x" v-if="loading"> <i class="fa fa-spinner fa-spin"></i> </div> <div v-if="propertyDetails"> <div class="page-head"> <div class="container"> <div class="row"> <div class="page-head-content"> <h1 class="page-title">{{propertyDetails.title}} </h1> </div> </div> </div> </div> <!-- End page header --> <!-- property area --> <div class="content-area single-property" style="background-color: #FCFCFC;"> <div class="container"> <div class="clearfix padding-top-40" > <div class="col-md-8 single-property-content prp-style-1 "> <div class="row"> <div class="light-slide-item"> <div class="clearfix"> <div class="favorite-and-print"> <a class="add-to-fav" href="#" v-if="!propertyDetails.isFav" @click.prevent="addToFavorites(propertyDetails.id); propertyDetails.isFav=true;" title="add to favorites"> <i class="fa fa-star-o"></i> </a> <a class="add-to-fav fav-selected" href="#" v-else @click.prevent="removeFromFavorites(propertyDetails.id); propertyDetails.isFav=false;" title="remove from favorites"> <i class="fa fa-star"></i> </a> <a class="printer-icon " href="javascript:window.print()"> <i class="fa fa-print"></i> </a> </div> <ul id="image-gallery" class="gallery list-unstyled cS-hidden"> <li v-for="picture in propertyDetails.pictures" :key="picture.id" :data-thumb="picture.image_url"> <img :src="picture.image_url" :alt="propertyDetails.title" /> </li> </ul> </div> </div> </div> <div class="single-property-wrapper"> <div class="single-property-header"> <h1 class="property-title pull-left">{{propertyDetails.title}} in {{propertyDetails.country.name}}</h1> <span class="property-price pull-right">{{getFormattedPrice(propertyDetails)}}</span> </div> <div class="property-meta entry-meta clearfix "> <div class="col-xs-6 col-sm-3 col-md-3 p-b-15"> <span class="property-info-icon icon-tag"> <img :src="`/template/assets/img/icon/${propertyDetails.status === 'sale'?'sale-orange.png':'rent-orange.png'}`"> </span> <span class="property-info-entry"> <span class="property-info-label">Status</span> <span class="property-info-value">For {{propertyDetails.status}}</span> </span> </div> <div class="col-xs-6 col-sm-3 col-md-3 p-b-15" v-if="propertyDetails.area"> <span class="property-info icon-area"> <img src="/template/assets/img/icon/room-orange.png?ac4fc4&ac4fc4"> </span> <span class="property-info-entry"> <span class="property-info-label">Area</span> <span class="property-info-value">{{propertyDetails.area}} <b class="property-info-unit">Sq Ft</b></span> </span> </div> <div class="col-xs-6 col-sm-3 col-md-3 p-b-15" v-if="propertyDetails.bedrooms"> <span class="property-info-icon icon-bed"> <img src="/template/assets/img/icon/bed-orange.png?ac4fc4&ac4fc4"> </span> <span class="property-info-entry"> <span class="property-info-label">Bedrooms</span> <span class="property-info-value">{{propertyDetails.bedrooms}}</span> </span> </div> <div class="col-xs-6 col-sm-3 col-md-3 p-b-15" v-if="propertyDetails.bathrooms"> <span class="property-info-icon icon-garage"> <img src="/template/assets/img/icon/shawer-orange.png?ac4fc4&ac4fc4"> </span> <span class="property-info-entry"> <span class="property-info-label">Bathrooms</span> <span class="property-info-value">{{propertyDetails.bathrooms}}</span> </span> </div> <div class="col-xs-6 col-sm-3 col-md-3 p-b-15" v-if="propertyDetails.rooms"> <span class="property-info-icon icon-garage"> <img src="/template/assets/img/icon/room-orange.png?ac4fc4&ac4fc4"> </span> <span class="property-info-entry"> <span class="property-info-label">Other rooms</span> <span class="property-info-value">{{propertyDetails.rooms}}</span> </span> </div> <div class="col-xs-6 col-sm-3 col-md-3 p-b-15" v-if="propertyDetails.garages"> <span class="property-info-icon icon-bed"> <img src="/template/assets/img/icon/cars-orange.png?ac4fc4&ac4fc4"> </span> <span class="property-info-entry"> <span class="property-info-label">Car garages</span> <span class="property-info-value">{{propertyDetails.garages}}</span> </span> </div> </div> <!-- .property-meta --> <div class="section" v-if="propertyDetails.description"> <h4 class="s-property-title">Description</h4> <div class="s-property-content"> <p>{{propertyDetails.description}}</p> </div> </div> <!-- End description area --> <div class="section additional-details"> <h4 class="s-property-title">Additional Details</h4> <ul class="additional-details-list clearfix"> <li v-if="propertyDetails.units"> <span class="col-xs-6 col-sm-4 col-md-4 add-d-title">Units</span> <span class="col-xs-6 col-sm-8 col-md-8 add-d-entry">{{propertyDetails.units}}</span> </li> <li v-if="propertyDetails.year_built"> <span class="col-xs-6 col-sm-4 col-md-4 add-d-title">Built In</span> <span class="col-xs-6 col-sm-8 col-md-8 add-d-entry">{{propertyDetails.year_built}}</span> </li> <li v-if="propertyDetails.floor_number"> <span class="col-xs-6 col-sm-4 col-md-4 add-d-title">Floor number</span> <span class="col-xs-6 col-sm-8 col-md-8 add-d-entry">{{propertyDetails.floor_number}}</span> </li> <li v-if="propertyDetails.property_finalizing"> <span class="col-xs-6 col-sm-4 col-md-4 add-d-title">Property Finalizing</span> <span class="col-xs-6 col-sm-8 col-md-8 add-d-entry">{{propertyDetails.property_finalizing}}</span> </li> <li v-if="propertyDetails.phone"> <span class="col-xs-6 col-sm-4 col-md-4 add-d-title">Phone</span> <span class="col-xs-6 col-sm-8 col-md-8 add-d-entry">{{propertyDetails.phone}}</span> </li> </ul> </div> <!-- End additional-details area --> <div class="section property-features" v-if="propertyDetails.features.length > 0"> <h4 class="s-property-title">Features</h4> <ul> <li v-for="feature in propertyDetails.features" :key="feature.id"><a href="#">{{feature.title}}</a></li> </ul> </div> <!-- End features area --> <div class="section property-video" v-if="propertyDetails.youtube_video"> <h4 class="s-property-title">Property Video</h4> <div class="video-thumb"> <a class="video-popup" :href="propertyDetails.youtube_video" title="Virtual Tour" target="_blank"> <img :src="propertyDetails.pictures[0].image_url" class="img-responsive wp-post-image" alt="Video"> <i class="fa fa-play-circle"></i> </a> </div> </div> <!-- End video area --> <div class="section property-share"> <h4 class="s-property-title">Share width your friends </h4> <div class="roperty-social"> <ul> <li><a title="Share this on facebok " href="#"><img src="/template/assets/img/social_big/facebook_grey.png?ac4fc4&ac4fc4"></a></li> <li><a title="Share this on delicious " href="#"><img src="/template/assets/img/social_big/delicious_grey.png?ac4fc4&ac4fc4"></a></li> <li><a title="Share this on tumblr " href="#"><img src="/template/assets/img/social_big/tumblr_grey.png?ac4fc4&ac4fc4"></a></li> <li><a title="Share this on twitter " href="#"><img src="/template/assets/img/social_big/twitter_grey.png?ac4fc4&ac4fc4"></a></li> <li><a title="Share this on linkedin " href="#"><img src="/template/assets/img/social_big/linkedin_grey.png?ac4fc4&ac4fc4"></a></li> </ul> </div> </div> <!-- End video area --> </div> </div> <div class="col-md-4 p0"> <aside class="sidebar sidebar-property blog-asside-right"> <PropertyUserInfo :property-details="propertyDetails" /> <div class="panel panel-default sidebar-menu wow fadeInRight animated"> <div class="panel-heading"> <h3 class="panel-title">Ads her </h3> </div> <div class="panel-body recent-property-widget"> <img src="/template/assets/img/ads.jpg?ac4fc4&ac4fc4"> </div> </div> </aside> </div> </div> </div> </div> </div> </template> <script> import {ref} from "vue"; import {useRoute} from "vue-router"; import {useToastError} from "../../composables/useToastError"; import {formatMoney} from "../../api/helpers"; import {rentAmountPerList} from "../../api/static_data"; import PropertyUserInfo from "../partials/PropertyUserInfo"; import {useFavorites} from "../../composables/useFavorites"; export default { name: "PropertySingle", components: {PropertyUserInfo}, setup() { const route = useRoute(); const {displayErrors} = useToastError(); const {addToFavorites, removeFromFavorites, isPropertyInFavorites} = useFavorites(); let propertyDetails = ref(null); let loading = ref(true); loading.value = true; window.axios.get(`/api/property/${route.params.id}`).then(response => { loading.value = false; if(!response.data.status) { displayErrors({data: {message: 'Unable to fetch property'}}); return; } propertyDetails.value = response.data.property; propertyDetails.value.isFav = isPropertyInFavorites(propertyDetails.value.id); setTimeout(initializeSlider, 300); }).catch(err => { loading.value = false; console.error(err); }); function getFormattedPrice(property) { if(property.status === 'Sale') { return '$' + formatMoney(property.price); } else { return '$' + formatMoney(property.price) +' ' + (rentAmountPerList.find(l => l.id == property.rent_amount_per)).name; } } function initializeSlider() { $(document).ready(function () { var slide = $('#image-gallery').lightSlider({ gallery: true, item: 1, thumbItem: 9, slideMargin: 0, speed: 500, auto: true, loop: true, onSliderLoad: function () { $('#image-gallery').removeClass('cS-hidden'); } }); }); } return { propertyDetails, loading, getFormattedPrice, addToFavorites, removeFromFavorites, isPropertyInFavorites } } } </script> <style scoped> .gallery .lslide { background-color: #ccc; } .single-property-header .property-title { font-size: 23px; } .single-property-header .property-price { font-size: 27px; } .video-popup { position: relative; } .video-popup .fa-play-circle { position: absolute; top: 143px; left: 181px; font-size: 56px; } .fav-selected { color: #FDC600; border-color: #FDC600; } </style>
resources/js/components/partials/PropertyTemplate.js
<template> <div class="box-two proerty-item"> <div class="item-thumb"> <router-link :to="getPropertyDetailUrl()"><img :src="property.pictures[0].image_url" :alt="property.title"></router-link> <div v-if="this.show_fav_icon" class="fav-wrapper"> <a class="add-to-fav" href="#" v-if="!property.isFav" @click.prevent="addToFav" title="add to favorites"> <i class="fa fa-star-o"></i> </a> <a class="add-to-fav fav-selected" href="#" v-else @click.prevent="removeFromFav" title="remove from favorites"> <i class="fa fa-star"></i> </a> </div> </div> <div class="item-entry overflow "> <h5><router-link :to="getPropertyDetailUrl()"> {{property.title}} </router-link></h5> <div class="dot-hr"></div> <span class="pull-left"><b> Area :</b> {{property.area}}m </span> <span class="proerty-price pull-right"> $ {{formatMoney(property.price)}}</span> <p style="display: none;" v-if="property.description">{{property.description}}</p> <div class="property-icon"> <span v-for="(feature, index) in getFeatures()" :key="index"> <img :src="'/template/assets/img/icon/' + feature.icon"> ({{feature.val}}){{index === getFeatures().length - 1 ? '' : '|'}} </span> <div class="dealer-action pull-right" v-if="show_actions"> <router-link :to="`/user/edit-property/${property.id}`" class="button">Edit </router-link> <a href="#" @click.prevent="deleteProperty" class="button delete_user_car">Delete</a> <router-link :to="getPropertyDetailUrl()" class="button">View</router-link> </div> </div> </div> </div> </template> <script> import {useToast} from "vue-toastification"; import {formatMoney, slugify} from "../../api/helpers"; import {useFavorites} from "../../composables/useFavorites"; export default { name: "PropertyTemplate", props: ["property", "show_actions", "show_fav_icon"], emits: ["onPropertyDeleted", "onAddToFavorite", "onRemoveFromFavorite"], setup(props, context) { const toast = useToast(); const {isPropertyInFavorites, addToFavorites, removeFromFavorites} = useFavorites(); props.property.isFav = isPropertyInFavorites(props.property.id); const getPropertyDetailUrl= function () { return `/property/${props.property.id}/${slugify(props.property.title)}`; } const getFeatures = function () { let features = []; if(props.property.bedrooms) { features = [...features, {icon: 'bed.png', val: props.property.bedrooms}]; } if(props.property.bathrooms) { features = [...features, {icon: 'shawer.png', val: props.property.bathrooms}]; } if(props.property.garages) { features = [...features, {icon: 'cars.png', val: props.property.garages}]; } return features; } const deleteProperty = function () { if(window.confirm("Are you sure to delete this property?")) { window.axios.delete(`/api/property/${props.property.id}`).then(response => { if (response.data.status) { toast.success(response.data.message); context.emit('onPropertyDeleted', props.property.id); } }); } } function addToFav() { addToFavorites(props.property.id); props.property.isFav=true; context.emit('onAddToFavorite', props.property.id); } function removeFromFav() { removeFromFavorites(props.property.id); props.property.isFav=false; context.emit('onRemoveFromFavorite', props.property.id); } return { formatMoney, getPropertyDetailUrl, getFeatures, deleteProperty, addToFav, removeFromFav } } } </script> <style scoped> .item-thumb { position: relative; } .fav-wrapper { position: absolute; top: 14px; left: 15px; font-size: 30px; } .fav-selected { color: #FDC600; border-color: #FDC600; } </style>
resources/js/components/pages/user/Favorites.js
<template> <div class="page-head"> <div class="container"> <div class="row"> <div class="page-head-content"> <h1 class="page-title">My Favorites</h1> </div> </div> </div> </div> <div class="content-area recent-property" style="background-color: #FFF;"> <div class="container"> <div class="row"> <div class="col-md-9 pr-30 padding-top-40 properties-page user-properties"> <div class="section" v-if="favoriteProperties.length > 0"> <div id="list-type" class="proerty-th-list"> <div class="col-md-4 p0" v-for="property of favoriteProperties" :key="property.id"> <PropertyTemplate :property="property" :show_actions="true" :show_fav_icon="true" @onRemoveFromFavorite="onRemoveFromFavorite" /> </div> </div> </div> <div class="section" v-else> <p class="text-center">No properties in favorite! <router-link to="/properties">Search properties</router-link></p> </div> </div> <div class="col-md-3 p0 padding-top-40"> <div class="blog-asside-right"> <div class="panel panel-default sidebar-menu wow fadeInRight animated" > </div> <div class="panel panel-default sidebar-menu wow fadeInRight animated"> </div> </div> </div> </div> </div> </div> </template> <script> import {ref} from "vue"; import {useFavorites} from "../../../composables/useFavorites"; import PropertyTemplate from "../../partials/PropertyTemplate"; export default { name: "Favorites", components: {PropertyTemplate}, setup() { const {getFavoritesFromStorage, searchFavorites} = useFavorites(); const favoriteProperties = ref([]); if(getFavoritesFromStorage().length === 0) { favoriteProperties.value = []; } else { searchFavorites(properties => { favoriteProperties.value = properties; }); } function onRemoveFromFavorite(pid) { favoriteProperties.value = favoriteProperties.value.filter(property => property.id !== parseInt(pid)); } return { favoriteProperties, onRemoveFromFavorite } } } </script>
Final Thoughts
At the end of this series you have learned a lot of concepts through out this tutorial about using Vue 3 and Laravel 8. We have seen the Vue 3 Compostion Api and composable functions which when makes our code reusable. There are some of remaining items i have left to you as exercise:
- Implementing the user profile page.
- Adding client side validation to property form
- Implementing an admin panel to view the properties and registered users.
Would be nice to see the repo for this project