Rails React clone complet de Turo
Caro est un clone complet de Turo, qui est un service de location de voitures peer-to-peer (« Airbnb pour les voitures »).
Caro propose Places Search Box
de suggestion automatique de Google et Google Maps
, avec une fonctionnalité dynamique pour rechercher l'index des voitures en fonction de la viewport
Google Map
et d'autres URL search parameters
.
Les utilisateurs disposent de fonctionnalités CRUD pour :
Caro dispose également d'une authentification utilisateur complète.
L'explorer ? ici!
Ou regardez le rapide
GitHub
Linkedin
Portefeuille
Dans Caro, les utilisateurs peuvent :
Google Map
avec les résultats de recherche de voitures tracés et affinez les résultats de recherche en :viewport
de Google Map
Places Search Box
avec suggestion automatique de GoogleCe projet est mis en œuvre avec les technologies suivantes :
React
et JavaScript
avec style CSS
, techniques AJAX
et Redux state
Ruby on Rails
avec JBuilder
pour sculpter les réponses du backendMaps JavaScript API
, Places API
et Geocoding API
pour activer la recherche de cartes et de lieuxAWS
pour l'hébergement des images des voitures et des utilisateurs et Active Storage
pour l'utilisation des images dans l'applicationFlatpickr
pour la sélection de la date du voyageHeroku
pour l'hébergement d'applicationsWebpack
pour regrouper et transpiler le code sourcenpm
pour gérer les dépendances du projetbcrypt
et has_secure_password
Active Record pour l'authentification de l'utilisateur Un utilisateur peut rechercher dynamiquement des voitures et affiner les résultats à l'aide de filtres et de la zone viewport
Google Map
.
Exemple de recherche commençant par le Places Search Box
suggestion automatique de Google :
Exemple de recherche commençant par les tuiles ville/expérience :
CarsSearchIndex
écoute les modifications apportées aux filtres (partagés en tant que URL search params
et stockés en tant que state variables
) et déclenche un appel backend pour récupérer les voitures en fonction de ces filtres. Avant d'envoyer la demande d'extraction, il normalise également les dates au format 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 ,
] ) ;
Notamment, faire glisser ou zoomer sur Google Map déclenche l'envoi d'une demande de récupération de voitures en modifiant la valeur bounds
, qui est un ensemble de valeurs lat/lng représentant la viewport
de Google Map
. Si un utilisateur choisit d'effectuer une recherche via le Places Search Box
, l'action de recherche alimente la carte en un emplacement et des coordonnées viewport
suggérées par Google, ce qui entraîne également une modification des bounds
. L'utilisation des coordonnées viewport
permet un niveau de zoom dynamique : des adresses spécifiques ("Ferry Building") zoomeront plus près sur la Google Map
que des adresses moins spécifiques ("San Francisco").
CarsSearchIndex/index.js
const 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 ]
) ;
Le backend filtre ensuite la liste des voitures qui répondent aux critères. Un filtre intéressant à noter est le dernier qui sélectionne uniquement les voitures qui ne sont pas réservées aux dates de voyage demandées. Il vérifie les trajets associés à chaque voiture et sélectionne la voiture uniquement si les scénarios de chevauchement possibles ne se produisent pas entre l'un des trajets de la voiture et les dates de trajet demandées.
cars_controller.rb
def 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
end
La liste de voitures résultante est transmise à Google Map
pour le placement des marqueurs et au composant CarList
qui mappe chacune à un CarSearchIndexItem
.
Caro permet aux utilisateurs de lancer une recherche Places Search Box
à partir de n'importe quelle page via une ligne de recherche dans la barre de navigation et la barre de recherche de la page de démarrage. L'action de recherche déclenche un saut vers l'index de recherche de voitures, qui envoie une récupération de voitures à l'aide des entrées de recherche.
Le défi consistait à forcer la récupération d'une voiture lors du lancement d'une recherche alors qu'elle était DÉJÀ sur la page d'index de recherche de voitures. Contrairement à d’autres cas, ici la page et la carte sont déjà rendues.
Ma solution initiale consistait à utiliser un handleSearchClick
personnalisé sur le composant de recherche SearchLine
qui vérifiait l'emplacement actuel de l'URL et poussait URL search params
s'il détectait qu'il se trouvait déjà sur la page de recherche. Dans d'autres cas, localStorage
a été utilisé pour transmettre des critères de recherche.
SearchLine/index.js
const 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/" ) ;
}
} ;
Pendant ce temps, CarMap
écoutait les modifications apportées à l'URL, analysait la valeur s'il trouvait la clé appropriée et modifiait le centre et le niveau de zoom de Google Map
.
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 ] ) ;
Le changement dans la fenêtre d'affichage de Google Map
a déclenché une modification des bounds
comme décrit ci-dessus, ce qui a provoqué une nouvelle expédition pour récupérer les voitures, actualisant les résultats de la recherche.
J'ai refactorisé mon code de recherche frontend pour utiliser URL search params
dans tous les cas au lieu de localStorage
. Le stockage de la requête de recherche dans l'URL présente des avantages tels que la possibilité de partager facilement les paramètres de recherche avec des amis et d'appuyer sur la touche Retour arrière pour revenir à la même requête de recherche.
Voici à quoi ressemble mon code maintenant. Cela reflète quelques refactors : localStorage
-> URL search params
, niveau de zoom statique -> coordonnées viewport
et « cliquez pour rechercher » -> « quitter la zone de saisie pour rechercher ».
SearchLine/index.js, location input box example
const 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 ) ;
} ;
Pour s'adapter au refactor, Google Map
extrait désormais les informations nécessaires des URL search params
et analyse les coordonnées viewport
pour déterminer la zone à afficher sur la carte.
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 stocke les données des utilisateurs, des voitures et autres dans plusieurs tables et joint les tables. Les pages frontales comportent des composants qui combinent les données de plusieurs tables. model associations
back-end et JBuilder
aident à envoyer les bonnes informations à chaque composant tout en minimisant les appels back-end.
À titre d'exemple, la page des favoris extrait tous les favoris uniquement pour l'utilisateur actuel et charge avec impatience les données associées.
favorites_controller.rb
def 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
end
JBuilder
construit ensuite une réponse qui inclut des informations pour chaque favori et pour la voiture attachée au favori, ainsi que les photos de la voiture et les informations sur l'hôte.
_favorite.json.jbuilder
json . 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
end
Étant donné que les informations de chaque favori incluent la voiture associée et les détails de l'hôte de la voiture, le composant FavoriteIndexItem
qui restitue chaque vignette de voiture favorite n'a pas besoin de récupérer ses propres données, mais obtient plutôt les informations requises via les accessoires en tant que favorite
de sa FavoritesPage
parent.
FavoritesPage/index.js
...
< div id = "favs-index-container" >
{ favorites && favorites . map ( ( favorite , idx ) => (
< FavoriteIndexItem key = { idx } favorite = { favorite } />
) ) }
</ div >
. . .
En conséquence, Redux state
ressemble à ceci :
Jetez un œil aux fichiers sources pour l'implémentation d'autres fonctionnalités notables :
Les améliorations à venir incluent :