Полный стек-клон Turo для Rails React
Caro — это полный клон Turo, одноранговой службы аренды автомобилей («Airbnb для автомобилей»).
Caro включает в себя Places Search Box
с автоматическим предложением Google и Google Maps
с динамической функциональностью для поиска по индексу автомобилей на основе viewport
Google Map
и других URL search parameters
.
Пользователи имеют функции CRUD для:
Caro также имеет полную авторизацию пользователя.
Исследовать это? здесь!
Или посмотрите краткий обзор
Гитхаб
Линкедин
Портфель
В Caro пользователи могут:
Google Map
с нанесенными на карту результатами поиска автомобилей и сузьте результаты поиска по:viewport
Google Map
Places Search Box
с автоматическим предложением GoogleДанный проект реализован с использованием следующих технологий:
React
и JavaScript
с использованием стилей CSS
, методов AJAX
и Redux state
Ruby on Rails
с JBuilder
для формирования ответов серверной частиMaps JavaScript API
, Places API
и Geocoding API
для включения поиска по карте и местоположениям.AWS
для размещения изображений автомобилей и пользователей и Active Storage
для использования изображений в приложении.Flatpickr
для выбора даты поездкиHeroku
для хостинга приложенийWebpack
для компоновки и передачи исходного кодаnpm
для управления зависимостями проектаbcrypt
и has_secure_password
Active Record для аутентификации пользователя Пользователь может динамически искать автомобили и сужать результаты, используя фильтры и область viewport
Google Map
.
Пример поиска, начинающийся с Places Search Box
с автоматическим предложением Google:
Пример поиска, начиная с плиток города/опыта:
CarsSearchIndex
прослушивает изменения в фильтрах (общих как URL search params
и хранящихся как state variables
) и запускает внутренний вызов для получения автомобилей на основе этих фильтров. Перед отправкой запроса на выборку он также нормализует даты в формате 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 ,
] ) ;
Примечательно, что перетаскивание или масштабирование карты Google Map запускает отправку запроса на получение автомобилей путем изменения значения bounds
, которое представляет собой набор значений широты и долготы, представляющих viewport
Google Map
. Если пользователь выбирает поиск через Places Search Box
, действие поиска передает местоположение и координаты viewport
, предложенные Google, на карту, что также приводит к изменению bounds
. Использование координат viewport
обеспечивает динамический уровень масштабирования — определенные адреса («Паромное здание») будут приближаться на Google Map
ближе, чем менее конкретные («Сан-Франциско»).
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 ]
) ;
Затем серверная часть отфильтровывает список автомобилей, соответствующих критериям. Стоит отметить интересный фильтр — последний, который отбирает только те автомобили, которые не забронированы на запрошенные даты поездки. Он проверяет поездки, связанные с каждым автомобилем, и выбирает автомобиль только в том случае, если возможные сценарии совпадения не происходят между поездками автомобиля и запрошенными датами поездки.
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
Результирующий список автомобилей передается на Google Map
для размещения маркеров и в компонент CarList
, который сопоставляет каждый из них с CarSearchIndexItem
.
Caro позволяет пользователям запускать поиск Places Search Box
с любой страницы через строку поиска на панели навигации и панель поиска на заставке. Действие поиска запускает переход к индексу поиска автомобилей, который отправляет выборку автомобилей с использованием входных данных поиска.
Задача заключалась в том, как заставить машину выбирать автомобили при запуске поиска, когда УЖЕ на индексной странице поиска автомобилей. В отличие от других случаев, здесь страница и карта уже отрисованы.
Моим первоначальным решением было использовать специальный handleSearchClick
для компонента поиска SearchLine
, который проверял текущее местоположение URL-адреса и отправлял URL search params
, если обнаруживал, что он уже находится на странице поиска. В других случаях localStorage
использовался для передачи критериев поиска.
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/" ) ;
}
} ;
Тем временем CarMap
прослушивал изменения в URL-адресе, анализировал значение, если находил соответствующий ключ, и изменял центр 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 ] ) ;
Изменение области просмотра Google Map
привело к изменению bounds
, как описано выше, что вызвало новую отправку автомобилей и обновление результатов поиска.
Я реорганизовал свой код поиска во внешнем интерфейсе, чтобы во всех случаях использовать URL search params
вместо localStorage
. Сохранение поискового запроса в URL-адресе имеет такие преимущества, как возможность легко делиться параметрами поиска с друзьями и нажимать клавишу Backspace, чтобы вернуться к тому же поисковому запросу.
Вот как сейчас выглядит мой код. Это отражает несколько рефакторингов: localStorage
-> URL search params
, статический уровень масштабирования -> координаты viewport
и «нажмите для поиска» -> «выйти из поля ввода для поиска».
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 ) ;
} ;
Чтобы учесть рефакторинг, Google Map
теперь извлекает необходимую информацию из URL search params
и анализирует координаты viewport
, чтобы определить, какую область отображать на карте.
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 хранит данные о пользователях, автомобилях и другие данные в нескольких таблицах и объединяет таблицы. Страницы внешнего интерфейса содержат компоненты, которые объединяют данные из нескольких таблиц. model associations
и JBuilder
помогают отправлять нужную информацию каждому компоненту, сводя при этом к минимуму внутренние вызовы.
Например, страница избранного извлекает все избранное только для текущего пользователя и быстро загружает связанные данные.
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
создает ответ, включающий информацию о каждом избранном и автомобиле, прикрепленном к избранному, а также фотографии автомобиля и информацию о хосте.
_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
Поскольку информация каждого избранного включает в себя сведения о связанном автомобиле и его хосте, компоненту FavoriteIndexItem
, который визуализирует каждую плитку избранного автомобиля, не требуется извлекать собственные данные, а вместо этого он получает необходимую информацию через реквизиты как favorite
из родительского элемента FavoritesPage
.
FavoritesPage/index.js
...
< div id = "favs-index-container" >
{ favorites && favorites . map ( ( favorite , idx ) => (
< FavoriteIndexItem key = { idx } favorite = { favorite } />
) ) }
</ div >
. . .
В результате Redux state
выглядит так:
Взгляните на исходные файлы для реализации других примечательных функций:
Предстоящие улучшения включают в себя: