Rails React clon completo de Turo
Caro es un clon completo de Turo, que es un servicio de alquiler de automóviles entre pares ('Airbnb para automóviles').
Caro presenta Places Search Box
de sugerencia automática de Google y Google Maps
, con funcionalidad dinámica para buscar en el índice de automóviles según la viewport
Google Map
y otros URL search parameters
.
Los usuarios cuentan con funcionalidades CRUD para:
Caro también tiene autenticación de usuario completa.
¿Explorarlo? ¡aquí!
O mira el rápido
Github
LinkedIn
Cartera
En Caro, los usuarios pueden:
Google Map
con resultados de búsqueda de automóviles trazados y limite los resultados de búsqueda mediante:viewport
de Google Map
Places Search Box
de sugerencia automática de GoogleEste proyecto se implementa con las siguientes tecnologías:
React
y JavaScript
con estilo CSS
, técnicas AJAX
y Redux state
Ruby on Rails
con JBuilder
para esculpir las respuestas del backendMaps JavaScript API
, Places API
y Geocoding API
para permitir la búsqueda de mapas y ubicacionesAWS
para alojar imágenes de automóviles y usuarios y Active Storage
para usar imágenes en la aplicaciónFlatpickr
para seleccionar la fecha del viajeHeroku
para alojamiento de aplicacionesWebpack
para agrupar y transpilar el código fuentenpm
para gestionar las dependencias del proyectobcrypt
y has_secure_password
Active Record para autenticación de usuario Un usuario puede buscar automóviles dinámicamente y limitar los resultados utilizando filtros y el área viewport
Google Map
.
Ejemplo de búsqueda que comienza con el Places Search Box
de sugerencia automática de Google:
Ejemplo de búsqueda que comienza con los mosaicos de ciudad/experiencia:
CarsSearchIndex
escucha los cambios en los filtros (compartidos como URL search params
y almacenados como state variables
) y activa una llamada de backend para buscar automóviles en función de esos filtros. Antes de enviar la solicitud de recuperación, también normaliza las fechas a 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 ,
] ) ;
En particular, arrastrar o hacer zoom en el mapa de Google activa el envío de una solicitud de búsqueda de automóviles cambiando el valor de bounds
, que es un conjunto de valores de latitud/longitud que representan la viewport
del Google Map
. Si un usuario opta por buscar a través del Places Search Box
, la acción de búsqueda introduce una ubicación y las coordenadas viewport
sugeridas por Google en el mapa, lo que también resulta en un cambio en bounds
. El uso de coordenadas viewport
permite un nivel de zoom dinámico: las direcciones específicas ('Ferry Building') se acercarán más en el Google Map
que las menos específicas ('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 ]
) ;
Luego, el backend filtra la lista de automóviles que cumplen con los criterios. Un filtro interesante a tener en cuenta es el último que selecciona solo los autos que no están reservados durante las fechas de viaje solicitadas. Verifica los viajes asociados con cada automóvil y solo selecciona el automóvil si los posibles escenarios de superposición no ocurren entre ninguno de los viajes del automóvil y las fechas de viaje 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
La lista de automóviles resultante se pasa al Google Map
para colocar marcadores y al componente CarList
que asigna cada uno a un CarSearchIndexItem
.
Caro permite a los usuarios iniciar una búsqueda Places Search Box
desde cualquier página a través de una línea de búsqueda de la barra de navegación y la barra de búsqueda de la página de inicio. La acción de búsqueda activa un salto al índice de búsqueda de automóviles, que envía una búsqueda de automóviles utilizando las entradas de búsqueda.
El desafío surgió con cómo forzar la búsqueda de un automóvil al iniciar una búsqueda cuando YA está en la página de índice de búsqueda de automóviles. A diferencia de otros casos, aquí la página y el mapa ya están renderizados.
Mi solución inicial fue utilizar un handleSearchClick
personalizado en el componente de búsqueda SearchLine
que verificaba la ubicación actual de la URL y enviaba URL search params
si detectaba que ya estaba en la página de búsqueda. En otros casos, se utilizó localStorage
para transmitir los criterios de búsqueda.
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/" ) ;
}
} ;
Mientras tanto, CarMap
escuchaba los cambios en la URL, analizaba el valor si encontraba la clave adecuada y cambiaba el centro y el nivel 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 ] ) ;
El cambio en la ventana gráfica de Google Map
provocó un cambio en bounds
como se describe anteriormente, lo que provocó que un nuevo despacho buscara automóviles y actualizara los resultados de la búsqueda.
Refactoricé mi código de búsqueda de interfaz para usar URL search params
en todos los casos en lugar de localStorage
. Almacenar la consulta de búsqueda en la URL tiene beneficios como la capacidad de compartir los parámetros de búsqueda fácilmente con amigos y presionar la tecla de retroceso para volver a la misma consulta de búsqueda.
Así es como se ve mi código ahora. Esto refleja algunos refactores: localStorage
-> URL search params
, nivel de zoom estático -> coordenadas viewport
y 'haga clic para buscar' -> 'salir del cuadro de entrada para buscar'.
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 adaptarse a la refactorización, Google Map
ahora extrae la información necesaria de URL search params
y analiza las coordenadas viewport
para determinar qué área mostrar en el 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 almacena datos de usuarios, automóviles y otros datos en varias tablas y las une. Las páginas frontales tienen componentes que combinan datos de varias tablas. model associations
de backend y JBuilder
ayudan a enviar la información correcta a cada componente mientras minimizan las llamadas de backend.
Como ejemplo, la página de favoritos extrae todos los favoritos solo del usuario actual y carga con entusiasmo los datos asociados.
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
Luego, JBuilder
construye una respuesta que incluye información para cada favorito y para el automóvil adjunto al favorito, junto con las fotografías del automóvil y la información del 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
Debido a que la información de cada favorito incluye el automóvil asociado y los detalles del host del automóvil, el componente FavoriteIndexItem
que representa cada mosaico de automóvil favorito no necesita obtener sus propios datos, sino que obtiene la información requerida a través de accesorios como favorite
de su FavoritesPage
principal.
FavoritesPage/index.js
...
< div id = "favs-index-container" >
{ favorites && favorites . map ( ( favorite , idx ) => (
< FavoriteIndexItem key = { idx } favorite = { favorite } />
) ) }
</ div >
. . .
Como resultado, Redux state
se ve así:
Eche un vistazo a los archivos fuente para implementar otras características notables:
Las próximas mejoras incluyen: