Rails React fullstack clone of Turo
Caro is a fullstack clone of Turo, which is a peer-to-peer car rental service ('Airbnb for cars').
Caro features Google's autosuggest Places Search Box and Google Maps, with dynamic functionality to search the cars index based on the Google Map viewport and other URL search parameters.
Users have CRUD functionalities for:
Caro also has full user auth.
Explore it ? here!
Or, watch the quick
Github
Linkedin
Portfolio
In Caro, users are able to:
Google Map with plotted car search results, and narrow down search results by:
Google Map's viewportPlaces Search BoxThis project is implemented with the following technologies:
React and JavaScript frontend with CSS styling, AJAX techniques, and Redux stateRuby on Rails backend with JBuilder to sculpt backend responsesMaps JavaScript API, Places API, and Geocoding API to enable the map and locations searchAWS for hosting car and user images and Active Storage for using images in appFlatpickr for trip date selectionHeroku for app hostingWebpack to bundle and transpile the source codenpm to manage project dependenciesbcrypt and has_secure_password Active Record macro method for user authA user can dynamically search cars and narrow down results using filters and Google Map viewport area.
Example search starting with the Google autosuggest Places Search Box:
Example search starting with the city/experience tiles:
The CarsSearchIndex listens to changes in filters (shared as URL search params and stored as state variables) and triggers a backend call to fetch cars based on those filters. Before dispatching the fetch request, it also normalizes dates to UTC.
CarsSearchIndex/index.js useEffect(() => {
if (
minPricing !== undefined &&
maxPricing !== undefined &&
bounds &&
experienceType &&
(superhostFilter === false || superhostFilter === true) &&
searchPageFromDate &&
searchPageUntilDate
) {
dispatch(
fetchCars({
minPricing,
maxPricing,
bounds,
superhostFilter: superhostFilter,
experienceType,
tripStart: handleDateChange(searchPageFromDate),
tripEnd: handleDateChange(searchPageUntilDate),
})
);
}
}, [
minPricing,
maxPricing,
bounds,
superhostFilter,
experienceType,
searchPageFromDate,
searchPageUntilDate,
dispatch,
]);Notably, dragging or zooming the Google Map triggers dispatching a cars fetch request by changing the value of bounds, which is a set of lat/lng values representing the Google Map's viewport. If a user opts to search via the Places Search Box, the search action feeds a location and Google-suggested viewport coordinates to the map, which also results in a change to bounds. Using viewport coordinates enables dynamic zoom level - specific addresses ('Ferry Building') will zoom in closer on the Google Map than less specific ones ('San Francisco').
CarsSearchIndex/index.jsconst mapEventHandlers = useMemo(
() => ({
click: (event) => {
const search = new URLSearchParams(event.latLng.toJSON()).toString();
},
idle: (map) => {
const newBounds = map.getBounds().toUrlValue();
if (newBounds !== bounds) {
setBounds(newBounds);
}
},
}),
[history]
);The backend then filters down the list of cars that meet criteria. An interesting filter to note is the last one that picks only cars that aren't booked out during the requested trip dates. It checks the trips associated with each car and only selects the car if the possible overlap scenarios aren't happening between any of the car's trips and the requested trip dates.
cars_controller.rbdef index
@cars = Car.includes(:host).includes(:reviews).includes(:trips)
@cars = @cars.in_bounds(bounds) if bounds
@cars = @cars.where(daily_rate: price_range) if price_range
if superhost_filter === 'true'
@cars = @cars.where(host_id: User.where(is_superhost: true).pluck(:id))
else
@cars
end
if experience_filter === 'all'
@cars
else
@cars = @cars.where(category: experience_filter)
end
if !date_range
@cars
elsif date_range === ["",""] || date_range === ["Invalid Date", "Invalid Date"]
@cars
else
@cars = @cars.where.not(id: Trip.where("(start_date <= ? AND end_date >= ?) OR (start_date <= ? AND end_date >= ?) OR (start_date >= ? AND end_date <= ?)", Date.parse(date_range[0]),Date.parse(date_range[0]) , Date.parse(date_range[1]), Date.parse(date_range[1]), Date.parse(date_range[0]), Date.parse(date_range[1]))
.select(:car_id))
end
endThe resultant cars list gets passed to the Google Map for placement of markers, and to the CarList component which maps each to a CarSearchIndexItem.
Caro allows users to start a Places Search Box search from any page via a navbar searchline and the splash page's searchbar. The search action triggers a jump over to the cars search index, which dispatches a cars fetch using the search inputs.
The challenge came with how to force a cars fetch when initiating a search when ALREADY on the cars search index page. Unlike other cases, here the page and map are already rendered.
My initial solution was to utilize a custom handleSearchClick on the SearchLine search component that checked current URL location and pushed URL search params if it detected it was already on the search page. In other cases, localStorage was used to pass along search criteria.
SearchLine/index.jsconst handleSearchClick = () => {
if (location.pathname.match(/^/cars/?$|^(?!/cars/d)/cars/?.*/)) {
setSearchPageFromDate(from);
setSearchPageUntilDate(until);
setSearchPageWhere(where);
history.push(`/cars?coords=${coords.lat},${coords.lng}`);
} else {
localStorage.setItem("fromDate", from);
localStorage.setItem("untilDate", until);
localStorage.setItem("where", where);
localStorage.setItem("coords", JSON.stringify(coords));
history.push("/cars/");
}
};Meanwhile the CarMap listened to changes in the URL, parsed the value if it found the appropriate key, and changed the Google Map's center and zoom level.
CarMap/index.js const location = useLocation();
useEffect(() => {
if (map) {
const urlParams = new URLSearchParams(location.search);
if (urlParams.get("coords")) {
const coords = urlParams
.get("coords")
.split(",")
.map((coord) => parseFloat(coord));
const newLatLng = new window.google.maps.LatLng(coords[0], coords[1]);
map.setCenter(newLatLng);
map.setZoom(14);
}
}
}, [location]);The change in the Google Map's viewport triggered a change to bounds as described above, which caused a new dispatch to fetch cars, refreshing the search results.
I refactored my frontend search code to use URL search params in all cases instead of localStorage. Storing the search query in the URL has benefits such as ability to share the search parameters easily with friends and press backspace to return to the same search query.
This is what my code looks like now. This reflects a few refactors: localStorage-> URL search params, static zoom level -> viewport coordinates, and 'click to search' -> 'exit input box to search'.
SearchLine/index.js, location input box exampleconst existingSearchParams = new URLSearchParams(location.search);
const handlePlaceOnSelect = (address) => {
setWhere(address);
geocodeByAddress(address)
.then((results) => {
if (results && results.length > 0) {
getLatLng(results[0]).then((latLng) => {
setCoords(latLng);
existingSearchParams.set("coords", `${latLng.lat},${latLng.lng}`);
existingSearchParams.delete("zoom");
//only update date params if they are different from the ones already in URL
let paramsDatesArr = [];
if (existingSearchParams.get("dates")) {
paramsDatesArr = existingSearchParams
.get("dates")
.split(",")
.map((dateStr) =>
new Date(dateStr).toLocaleDateString("en-US")
);
}
const dateRangeArr = dateRange.map((date) =>
date.toLocaleDateString("en-US")
);
if (
paramsDatesArr[0] !== dateRangeArr[0] ||
paramsDatesArr[1] !== dateRangeArr[1]
) {
existingSearchParams.set("dates", dateRange);
}
//set viewport from Google Place's API callback
if (results[0].geometry.viewport) {
const viewportCoords = results[0].geometry.viewport.toJSON();
existingSearchParams.set(
"viewport",
`${viewportCoords.east},${viewportCoords.west}, ${viewportCoords.north}, ${viewportCoords.south}`
);
}
existingSearchParams.set("location", address);
history.push({
pathname: "/cars",
search: existingSearchParams.toString(),
});
});
} else {
console.error("No results found for the address:", address);
}
})
.catch((error) => console.error("Geocoding error:", error));
setValidPlace(true);
};To accomodate the refactor, the Google Map now pulls the needed info from URL search params and parses viewport coordinates to determine what area to show on the map.
CarMap/index.js const urlParams = new URLSearchParams(location.search);
const coordsParams = urlParams.get("coords");
const zoomParams = urlParams.get("zoom");
const viewportParams = urlParams.get("viewport");
useEffect(() => {
if (map) {
if (coordsParams) {
const coords = coordsParams
.split(",")
.map((coord) => parseFloat(coord));
const newLatLng = new window.google.maps.LatLng(coords[0], coords[1]);
map.setCenter(newLatLng);
if (viewportParams) {
const bounds = new window.google.maps.LatLngBounds();
const coords = viewportParams
.split(",")
.map((coord) => parseFloat(coord.trim()));
const west = coords[0];
const east = coords[1];
const north = coords[2];
const south = coords[3];
bounds.extend(new window.google.maps.LatLng(north, west));
bounds.extend(new window.google.maps.LatLng(south, east));
map.fitBounds(bounds);
} else if (zoomParams) {
map.setZoom(parseInt(zoomParams));
} else {
map.setZoom(15);
}
}
}
}, [coordsParams, zoomParams, viewportParams, map]);Caro stores user, car, and other data across several tables and joins tables. Frontend pages have components that combine data from across several tables. Backend model associations and JBuilder help send the right info to each component while minimizing backend calls.
As an example, the favorites page pulls all favorites for only the current user and eagerly loads associated data.
favorites_controller.rbdef index
if current_user
@favorites = Favorite.includes(:driver).includes(:car).where(driver_id: current_user.id).order(updated_at: :desc)
else
head :no_content
end
endJBuilder then constructs a response that includes info for each favorite and for the car that is attached to the favorite, along with the car's photos and host info.
_favorite.json.jbuilderjson.extract! favorite,
:id,
:driver_id,
:car_id,
:created_at
json.car do
json.extract! favorite.car, :id,
:make,
:model,
:year,
:mpg,
:doors_count,
:seats_count,
:category,
:automatic,
:description,
:guidelines,
:daily_rate,
:location,
:active,
:host_id,
:created_at,
:avg_cleanliness_rating,
:avg_maintenance_rating,
:avg_communication_rating,
:avg_convenience_rating,
:avg_accuracy_rating,
:trips_count,
:reviews_count,
:city
if favorite.car.photos.attached?
photos = []
favorite.car.photos.each do |photo|
photos << url_for(photo)
end
json.photos_url photos
end
json.host do
json.extract! favorite.car.host, :id, :first_name, :last_name, :approved_to_drive, :is_superhost, :is_clean_certified, :email, :phone_number, :created_at, :updated_at, :trips_count, :user_rating, :hosted_cars_count
end
endBecause each favorite's info includes associated car and car's host detail, the FavoriteIndexItem component that renders each favorited car tile doesn't need to fetch its own data, but rather gets the required information via props as favorite from its parent FavoritesPage.
FavoritesPage/index.js...
<div id="favs-index-container">
{favorites && favorites.map((favorite, idx) => (
<FavoriteIndexItem key={idx} favorite={favorite} />
))}
</div>
...As a result, Redux state looks like this:
Take a look at the source files for implementation of other notable features:
Upcoming improvements include: