Clone fullstack do Rails React do Turo
Caro é um clone fullstack do Turo, que é um serviço de aluguel de automóveis ponto a ponto ('Airbnb para carros').
Caro apresenta Places Search Box
de sugestão automática do Google e Google Maps
, com funcionalidade dinâmica para pesquisar o índice de carros com base na viewport
Google Map
e outros URL search parameters
.
Os usuários têm funcionalidades CRUD para:
Caro também possui autenticação de usuário completa.
Explorar isso? aqui!
Ou assista ao rápido
GitHub
Linkedin
Portfólio
No Caro, os usuários são capazes de:
Google Map
com resultados de pesquisa de carros traçados e restrinja os resultados da pesquisa por:viewport
do Google Map
Places Search Box
de sugestão automática do GoogleEste projeto é implementado com as seguintes tecnologias:
React
e JavaScript
com estilo CSS
, técnicas AJAX
e Redux state
Ruby on Rails
com JBuilder
para esculpir respostas de backendMaps JavaScript API
, Places API
e Geocoding API
para ativar a pesquisa de mapas e locaisAWS
para hospedar imagens de carros e usuários e Active Storage
para usar imagens no aplicativoFlatpickr
para seleção da data da viagemHeroku
para hospedagem de aplicativosWebpack
para agrupar e transpilar o código-fontenpm
para gerenciar dependências do projetobcrypt
e has_secure_password
Active Record para autenticação do usuário Um usuário pode pesquisar carros dinamicamente e restringir os resultados usando filtros e a área viewport
Google Map
.
Exemplo de pesquisa começando com a Places Search Box
de sugestão automática do Google:
Exemplo de pesquisa começando com os blocos de cidade/experiência:
O CarsSearchIndex
escuta alterações nos filtros (compartilhados como URL search params
e armazenados como state variables
) e aciona uma chamada de back-end para buscar carros com base nesses filtros. Antes de despachar a solicitação de busca, ele também normaliza as datas para 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 ,
] ) ;
Notavelmente, arrastar ou ampliar o mapa do Google aciona o envio de uma solicitação de busca de carros alterando o valor de bounds
, que é um conjunto de valores lat/lng que representam a viewport
do Google Map
. Se um usuário optar por pesquisar por meio da Places Search Box
, a ação de pesquisa alimenta o mapa com um local e as coordenadas viewport
sugeridas pelo Google, o que também resulta em uma alteração nos bounds
. O uso de coordenadas viewport
permite o nível de zoom dinâmico - endereços específicos ('Ferry Building') aumentarão o zoom no Google Map
do que endereços menos específicos ('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 ]
) ;
O back-end então filtra a lista de carros que atendem aos critérios. Um filtro interessante a ser observado é o último que seleciona apenas os carros que não estão reservados nas datas de viagem solicitadas. Ele verifica as viagens associadas a cada carro e só seleciona o carro se os possíveis cenários de sobreposição não estiverem acontecendo entre nenhuma das viagens do carro e as datas de viagem solicitadas.
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
A lista de carros resultante é passada para o Google Map
para colocação de marcadores e para o componente CarList
que mapeia cada um para CarSearchIndexItem
.
Caro permite que os usuários iniciem uma pesquisa Places Search Box
a partir de qualquer página por meio de uma linha de pesquisa na barra de navegação e na barra de pesquisa da página inicial. A ação de pesquisa aciona um salto para o índice de pesquisa de carros, que despacha uma busca de carros usando as entradas de pesquisa.
O desafio surgiu em como forçar a busca de carros ao iniciar uma pesquisa quando JÁ estiver na página de índice de pesquisa de carros. Ao contrário de outros casos, aqui a página e o mapa já estão renderizados.
Minha solução inicial foi utilizar um handleSearchClick
personalizado no componente de pesquisa SearchLine
que verificava a localização atual do URL e enviava URL search params
se detectasse que já estava na página de pesquisa. Em outros casos, localStorage
foi usado para repassar os critérios de pesquisa.
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/" ) ;
}
} ;
Enquanto isso, o CarMap
ouvia as alterações no URL, analisava o valor se encontrasse a chave apropriada e alterava o centro e o nível de zoom do 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 ] ) ;
A mudança na janela de visualização do Google Map
desencadeou uma mudança nos bounds
conforme descrito acima, o que causou um novo despacho para buscar carros, atualizando os resultados da pesquisa.
Refatorei meu código de pesquisa de front-end para usar URL search params
em todos os casos, em vez de localStorage
. Armazenar a consulta de pesquisa no URL traz benefícios como a capacidade de compartilhar facilmente os parâmetros de pesquisa com amigos e pressionar backspace para retornar à mesma consulta de pesquisa.
Esta é a aparência do meu código agora. Isso reflete alguns refatoradores: localStorage
-> URL search params
, nível de zoom estático -> coordenadas viewport
e 'clique para pesquisar' -> 'sair da caixa de entrada para pesquisar'.
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 ) ;
} ;
Para acomodar a refatoração, o Google Map
agora extrai as informações necessárias dos URL search params
e analisa as coordenadas viewport
para determinar qual área mostrar no mapa.
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 armazena usuários, carros e outros dados em várias tabelas e une tabelas. As páginas frontend possuem componentes que combinam dados de várias tabelas. model associations
de back-end e JBuilder
ajudam a enviar as informações corretas para cada componente, minimizando as chamadas de back-end.
Por exemplo, a página de favoritos extrai todos os favoritos apenas do usuário atual e carrega avidamente os dados associados.
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
então constrói uma resposta que inclui informações para cada favorito e para o carro anexado ao favorito, juntamente com as fotos do carro e informações do host.
_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
Como as informações de cada favorito incluem o carro associado e os detalhes do host do carro, o componente FavoriteIndexItem
que renderiza cada bloco de carro favorito não precisa buscar seus próprios dados, mas obtém as informações necessárias por meio de props as favorite
de seu pai FavoritesPage
.
FavoritesPage/index.js
...
< div id = "favs-index-container" >
{ favorites && favorites . map ( ( favorite , idx ) => (
< FavoriteIndexItem key = { idx } favorite = { favorite } />
) ) }
</ div >
. . .
Como resultado, Redux state
fica assim:
Dê uma olhada nos arquivos de origem para implementação de outros recursos notáveis:
As próximas melhorias incluem: