Our final project at the Le Wagon Web Development Coding Bootcamp I attended in the fall of 2022. Homify is an app for finding apartments for rent by swiping and matching. The web application was made in two weeks and resulted in a MVP that was presented to an audience at the bootcamp's demo day. See embeded video from YouTube.
Project team:
- Sign up and create a profile with a profile picture.
- Create a new search.
- Filter search results by editing your search.
- Swipe on apartments to find a match.
- Instantly book an appointments for viewings on your matches.
- Live instant chat feature with matches.
Homify is a web application optimized for mobile and built with Ruby on Rails on both backend and frontend. Data is stored in a PostgreSQL database and Cloudinary is used for cloud storage of the profile and apartment image files. Authentication and authorization is being handled with the Devise gem. The apps search feature is made with the PgSearch gem.
The application is additionally supported by Webpack, simple_form, maps from Mapbox, stimulus and bootstrap.
The Tinder-like swipe feature to register a "like" or a "nope" turned out to be quite a challenge. The final code is built upon the Javascript library Hammer.js, which enables touch gestures to web applications. With a lot of hits and misses the final result both looked good and was running smoothly in the web browser.
// app/javascript/controllers/swipe_controller.js
import { Controller } from "@hotwired/stimulus"
import '../swipe_animation'
// Connects to data-controller="swipe"
export default class extends Controller {
static targets = ["match", "like", "nope"]
// modal pop-up with show listing
toggle(event) {
document.getElementById(\`button\${event.path[2].dataset.id}\`).click()
}
connect() {
// Swiping function is made with Hammer.JS, see below.
/*! Hammer.JS - v2.0.8 - 2016-04-23
* https://hammerjs.github.io/
*
* Copyright (c) 2016 Jorik Tangelder;
* Licensed under the MIT license */
// function to show match-animation for a set time
let matchCounter = 0;
let matchAnimation = () => {
matchCounter += 1;
if (matchCounter > 2) {
const match = document.getElementById("match-animation");
match.classList.remove('d-none');
setTimeout(() => {
match.classList.add('d-none')
}, 2300);
}
}
//function to fade in/out "like"
let fadeInOutLike = () => {
const like = document.getElementById("fade-in-out-like");
like.classList.remove('d-none');
setTimeout(() => {
like.classList.add('d-none');
},2000);
}
//function to fade in/out "like"
let fadeInOutNope = () => {
const nope = document.getElementById("fade-in-out-nope");
nope.classList.remove('d-none');
setTimeout(() => {
nope.classList.add('d-none');
}, 2000);
}
// selecting element with class profile to let profiles
let profiles = document.querySelectorAll('.profile');
const maxAngle = 42;
const smooth = 0.3;
const threshold = 42;
const thresholdMatch = 150;
profiles.forEach(setupDragAndDrop);
function setupDragAndDrop(profile) {
const hammertime = new Hammer(profile);
hammertime.on('pan', function (e) {
profile.classList.remove('profile--back');
let posX = e.deltaX;
let posY = Math.max(0, Math.abs(posX * smooth) - 42);
let angle = Math.min(Math.abs(e.deltaX * smooth / 100), 1) * maxAngle;
if (e.deltaX < 0) {
angle *= -1;
}
// user is selecting and holding the card and can move it left or right, back and forth.
profile.style.transform = \`translateX(\${posX}px) translateY(\${posY}px) rotate(\${angle}deg)\`;
profile.classList.remove('profile--matching');
profile.classList.remove('profile--nexting');
if (posX > thresholdMatch) {
profile.classList.add('profile--matching');
console.log('✅ User is about to swipe yes')
fadeInOutLike();
} else if (posX < -thresholdMatch) {
profile.classList.add('profile--nexting');
console.log('⛔ User is about to swipe no');
fadeInOutNope();
}
// user releases card on the left (nope),
// near the middle (back to middle, no action),
// or on the right (yes)
if (e.isFinal) {
// right side, yes.
profile.style.transform = \`\`;
if (posX > thresholdMatch) {
profile.classList.add('profile--match');
console.log('✅ Yes (user is created in matches table)');
matchAnimation();
if (matchCounter > 2) {
// creating a new match in matches-table.
console.log( \`matchCounter is \${matchCounter}\`);
console.dir(document.location.search.split('=')[1]);
const searchId = document.location.search.split('=')[1];
const url = \`/listings/\${profile.dataset.id}/matches\`;
const body = {match: {listing_id: profile.dataset.id, search_id: searchId}};
fetch(url, {
method: "POST",
body: JSON.stringify(body),
headers: {
'Content-Type': 'application/json',
"X-CSRF-Token": document.querySelector("meta[name=csrf-token]").content}
})
}
// left side, nope
} else if (posX < -thresholdMatch) {
profile.classList.add('profile--next');
console.log('⛔ No!');
} else {
profile.classList.add('profile--back');
}
}
});
}
}
}
The listings controller handles the search based on given params, along with rest of the CRUD-actions.
# app/controllers/listings_controller.rb
class ListingsController < ApplicationController
skip_before_action :authenticate_user!, only: [:index, :show]
def index
# user cannot see and swipe on own listings.
@listings = Listing.where.not(user_id: current_user.id)
if params[:search].present?
@search = Search.find(params[:search])
["price",
"bedrooms",
"bathrooms",
"address",
"city",
"country",
"street",
"postcode",
"district",
"photos",
"description",
"property_type",
"area_size",
"floor",
"garden",
"balcony",
"parking",
"family_status",
"occupation",
"pets",
"lift",
"furnished"].each do |column|
value = @search.send(column.to_sym)
if value.present?
if column == "price"
query = "#{column} > :min AND #{column} < :max"
else
query = "#{column} = :value"
end
p query, value
@listings = @listings.where(query, value: value, min: @search.price, max: @search.price_max)
end
end
else
@listings = @listings.global_search(params[:city]) if params[:city].present?
if params[:min_price].present? && params[:max_price].present?
@listings = @listings.where(price: params[:min_price]..params[:max_price])
elsif params[:min_price].present?
@listings = @listings.where('price >= ?', params[:min_price])
elsif params[:max_price].present?
@listings = @listings.where('price <= ?', params[:max_price])
end
@listings = @listings.where(bedrooms: params[:bedrooms]) if params[:bedrooms].present?
end
# for creating the maps in the info windows
@markers = @listings.geocoded.map do |listing|
{
lat: listing.latitude,
lng: listing.longitude,
id: listing.id,
info_window: render_to_string(partial: "info_window", locals: {listing: listing})
}
end
end
def show
@listing = Listing.find(params[:id])
@viewing = Viewing.new
@match = current_user.matches.find_by(listing: @listing)
@listings = Listing.where(id: @listing.id)
@markers = @listings.geocoded.map do |listing|
{
lat: listing.latitude,
lng: listing.longitude,
id: listing.id,
info_window: render_to_string(partial: "info_window", locals: {listing: listing})
}
end
end
def new
@listing = Listing.new
end
def create
@user = current_user
@listing = Listing.new(listing_params)
@listing.user = current_user
@listing.save
if @listing.save!
redirect_to listing_path(@listing.id)
else
render :new
end
end
def edit
@listing = Listing.find(params[:id])
end
def update
@user = current_user
@listing = Listing.find(params[:id])
@listing.update!(listing_params)
redirect_to listing_path(@listing.id)
end
def destroy
@listing = Listing.find(params[:id])
@listing.destroy
redirect_to profile_path
end
private
def listing_params
params.require(:listing).permit(:price, :bedrooms, :bathrooms, :address, :description, :property_type, :area_size, :floor, :garden, :balcony, :parking, :family_status, :occupation, :pets, :lift, :furnished, :user_id, :city, :district, :postcode, :street, :country, photos: [])
end
end
All the cards are stacked on top of each other and displayed to the user. The user can then swipe through the stack, choosing a "nope" or a "like". When clicking a card(a listing), a modal shows more information about the listing (photos, address, price, properties, map and more). If there are no more cards left the user will be informed that there are no more listings that matches the chosen criterias.
<!-- app/views/listings/index.html.erb -->
<div data-controller="swipe">
<div class="background-text">
<p>There are no more listings that match your criteria. Go back and edit your search.</p>
</div>
<%# iterating through all the listings to create the swipe-cards %>
<div class="profiles">
<% @listings.each do |listing| %>
<!-- Modal -->
<button
data-swipe-target="modal"
type="button"
class="btn btn-primary d-none"
data-bs-toggle="modal"
data-bs-target="#exampleModal<%= listing.id %>"
id="button<%= listing.id %>"
>
</button>
<div class="bg-modal modal fade" id="exampleModal<%= listing.id %>" tabindex="-1" aria-labelledby="exampleModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="exampleModalLabel"></h5>
<i type="button" class="fa-solid fa-xmark btn-close-custom" data-bs-dismiss="modal" aria-label="Close"></i>
</div>
<div class="modal-body">
<%# listing show page %>
<div>
<div class="apartment_name">
<h3><%="#{listing.district}"%></h3>
</div>
<div class="show_image">
<div id="carouselExampleSlidesOnly" class="carousel slide" data-bs-ride="carousel">
<div class="carousel-inner">
<% listing.photos.each_with_index do |photo, index| %>
<div class="carousel-item <%="active" if index == 0%>">
<%= cl_image_tag photo.key, height: 400, width: 400, crop: :fill %>
</div>
<% end %>
</div>
</div>
</div>
<div class="apartment_information_card mt-4">
<h2 class="listing-price">€<%= listing.price.to_s.gsub(/\B(?=(...)*\b)/, '.') %></h2>
</div>
<div class="d-flex flex-row mt-4">
<ul class="d-flex flex-column col-6" style="list-style: none; margin: 0px; padding: 0px">
<li><p><i class="fa-solid fa-bed me-4"></i><%="#{listing.bedrooms}"%></p></li>
<li><p><i class="fa-solid fa-shower me-4"></i><%="#{listing.bathrooms}"%></p></li>
<li><p><i class="fa-solid fa-square me-4"></i><%="#{listing.area_size} sqm"%></p></li>
<li><p><i class="fa-solid fa-stairs me-4"></i><%="#{listing.floor}"%></p></li>
<li><p><i class="fa-solid fa-sun me-4"></i><%="#{listing.balcony}"%></p></li>
</ul>
<ul class="d-flex flex-column col-6" style="list-style: none; margin: 0px; padding: 0px">
<li><p><i class="fa-solid fa-house me-4"></i><%="#{listing.property_type}"%></p></li>
<li><p><i class="fa-solid fa-chair me-4"></i><%= listing.furnished ? "yes" : "no" %></p></li>
<li><p><i class="fa-solid fa-car me-4"></i><%= listing.parking ? "yes" : "no" %></p></li>
<li><p><i class="fa-solid fa-tree me-4"></i><%= listing.garden ? "yes" : "no" %></p></li>
<li><p><i class="fa-solid fa-elevator me-4"></i><%= listing.lift ? "yes" : "no" %></p></li>
</ul>
</div>
<div style="width: 100%; height: 300px; border-radius: 10px;"
data-controller="map"
data-map-markers-value="<%= @markers.select { |marker| marker[:id] == listing.id }.to_json %>"
data-map-api-key-value="<%= ENV['MAPBOX_API_KEY'] %>">
</div>
<div class="apartment_information_card mt-4">
<p><%="#{listing.description}"%></p>
</div>
</div>
</div>
</div>
</div>
</div>
<%# Swipe card %>
<div
class="profile"
data-id="<%= listing.id %>"
data-action="click->swipe#toggle"
>
<div class="swipe_card mb-5">
<div class="match-percentage"><p><%= rand(69..97) %>% match</p></div>
<div class="swipe_card_image"
style="background-image: linear-gradient(rgba(0,0,0,0.1), rgba(0,0,0,0.8)),
url(<%= cl_image_path listing.photos.first.key %>);
display: flex; align-items: end">
<div class="swipe_card_header ms-3">
<h2><%= "#{listing.district}" %></h2>
<h3><%= "#{listing.city}" %></h3>
<h2 class="listing-price">€ <%= listing.price.to_s.gsub(/\B(?=(...)*\b)/, '.') %></h2>
</div>
</div>
<div class="swipe_card_description mt-4 ms-3">
<div class="d-flex flex-row mt-4">
<ul class="d-flex flex-column col-6" style="list-style: none; margin: 0px; padding: 0px">
<li><p><i class="fa-solid fa-square me-4"></i><%="#{listing.area_size} sqm"%></p></li>
<li><p><i class="fa-solid fa-bed me-4"></i><%="#{listing.bedrooms}"%></p></li>
<li><p><i class="fa-solid fa-shower me-4"></i><%="#{listing.bathrooms}"%></p></li>
<li><p><i class="fa-solid fa-stairs me-4"></i><%="#{listing.floor}"%></p></li>
</ul>
<ul class="d-flex flex-column col-6" style="list-style: none; margin: 0px; padding: 0px">
<li><p><i class="fa-solid fa-house me-4"></i><%="#{listing.property_type}"%></p></li>
<li><p><i class="fa-solid fa-chair me-4"></i><%="#{listing.furnished ? 'yes' : 'no'}"%></p></li>
<li><p><i class="fa-solid fa-elevator me-4"></i><%="#{listing.lift ? 'yes' : 'no'}"%></p></li>
<li><p><i class="fa-solid fa-car me-4"></i><%="#{listing.parking ? 'yes' : 'no'}"%></p></li>
</ul>
</div>
</div>
</div>
</div>
<% end %>
</div>
<%# match_animation %>
<div id="match-animation" class="d-none" data-swipe-target="match">
<div class="blur"></div>
<div class="container-animation">
<div class="coast">
<div class="wave-rel-wrap">
<div class="wave"></div>
</div>
</div>
<div class="coast delay">
<div class="wave-rel-wrap">
<div class="wave delay"></div>
</div>
</div>
<div class="text text-m">m</div>
<div class="text text-a">a</div>
<div class="text text-t">t</div>
<div class="text text-c">c</div>
<div class="text text-h">h</div>
</div>
</div>
<%# like_nope_animation %>
<%# like %>
<div class="fade-in-out d-none" id="fade-in-out-like" data-swipe-target="like">
<div class="fade-in-out-container">
<h1 class="fade-in-out-text">like</h1>
</div>
</div>
<%# nope %>
<div class="fade-in-out-nope d-none" id="fade-in-out-nope" data-swipe-target="nope">
<div class="fade-in-out-container-nope">
<h1 class="fade-in-out-text-nope">nope</h1>
</div>
</div>
</div>