Rails React tiruan fullstack dari Turo
Caro adalah tiruan fullstack dari Turo, yang merupakan layanan penyewaan mobil peer-to-peer ('Airbnb untuk mobil').
Caro menampilkan Places Search Box
Saran Otomatis Google dan Google Maps
, dengan fungsi dinamis untuk mencari indeks mobil berdasarkan viewport
Google Map
dan URL search parameters
lainnya.
Pengguna memiliki fungsi CRUD untuk:
Caro juga memiliki autentikasi pengguna penuh.
Jelajahi itu? Di Sini!
Atau, perhatikan cepatnya
Github
Linkedin
Portofolio
Di Caro, pengguna dapat:
Google Map
dengan hasil penelusuran mobil yang diplot, dan persempit hasil penelusuran berdasarkan:viewport
Google Map
Places Search Box
Saran Otomatis GoogleProyek ini dilaksanakan dengan teknologi berikut:
React
dan JavaScript
dengan gaya CSS
, teknik AJAX
, dan Redux state
Ruby on Rails
dengan JBuilder
untuk membentuk respons backendMaps JavaScript API
, Places API
, dan Geocoding API
untuk mengaktifkan pencarian peta dan lokasiAWS
untuk menghosting gambar mobil dan pengguna dan Active Storage
untuk menggunakan gambar dalam aplikasiFlatpickr
untuk pemilihan tanggal perjalananHeroku
untuk hosting aplikasiWebpack
untuk menggabungkan dan mentranspilasi kode sumbernpm
untuk mengelola dependensi proyekbcrypt
dan has_secure_password
metode makro Rekaman Aktif untuk autentikasi pengguna Pengguna dapat mencari mobil secara dinamis dan mempersempit hasil menggunakan filter dan area viewport
Google Map
.
Contoh pencarian yang dimulai dengan Places Search Box
Saran Otomatis Google :
Contoh penelusuran yang dimulai dengan ubin kota/pengalaman:
CarsSearchIndex
mendengarkan perubahan dalam filter (dibagikan sebagai URL search params
dan disimpan sebagai state variables
) dan memicu panggilan backend untuk mengambil mobil berdasarkan filter tersebut. Sebelum mengirimkan permintaan pengambilan, ini juga menormalkan tanggal ke 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 ,
] ) ;
Khususnya, menyeret atau memperbesar Google Map memicu pengiriman permintaan pengambilan mobil dengan mengubah nilai bounds
, yang merupakan kumpulan nilai lintang/panjang yang mewakili viewport
Google Map
. Jika pengguna memilih untuk menelusuri melalui Places Search Box
, tindakan penelusuran memasukkan lokasi dan koordinat viewport
yang disarankan Google ke peta, yang juga mengakibatkan perubahan pada bounds
. Penggunaan koordinat viewport
memungkinkan tingkat zoom dinamis - alamat tertentu ('Gedung Ferry') akan memperbesar lebih dekat di Google Map
dibandingkan alamat yang kurang spesifik ('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 ]
) ;
Backend kemudian memfilter daftar mobil yang memenuhi kriteria. Filter yang menarik untuk diperhatikan adalah filter terakhir yang hanya memilih mobil yang tidak dipesan selama tanggal perjalanan yang diminta. Ia memeriksa perjalanan yang terkait dengan setiap mobil dan hanya memilih mobil jika kemungkinan skenario tumpang tindih tidak terjadi antara perjalanan mobil mana pun dan tanggal perjalanan yang diminta.
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
Daftar mobil yang dihasilkan diteruskan ke Google Map
untuk penempatan penanda, dan ke komponen CarList
yang memetakan masing-masing ke CarSearchIndexItem
.
Caro memungkinkan pengguna untuk memulai pencarian Places Search Box
dari halaman mana pun melalui baris pencarian bilah navigasi dan bilah pencarian halaman pembuka. Tindakan pencarian memicu lompatan ke indeks pencarian mobil, yang mengirimkan pengambilan mobil menggunakan input pencarian.
Tantangannya adalah bagaimana memaksa pengambilan mobil saat memulai pencarian ketika SUDAH ada di halaman indeks pencarian mobil. Berbeda dengan kasus lainnya, di sini halaman dan peta sudah dirender.
Solusi awal saya adalah dengan menggunakan handleSearchClick
khusus pada komponen pencarian SearchLine
yang memeriksa lokasi URL saat ini dan mendorong URL search params
jika terdeteksi sudah ada di halaman pencarian. Dalam kasus lain, localStorage
digunakan untuk meneruskan kriteria pencarian.
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/" ) ;
}
} ;
Sementara itu CarMap
mendengarkan perubahan pada URL, menguraikan nilainya jika menemukan kunci yang sesuai, dan mengubah pusat Google Map
dan tingkat zoom.
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 ] ) ;
Perubahan pada area pandang Google Map
memicu perubahan bounds
seperti dijelaskan di atas, yang menyebabkan pengiriman baru untuk mengambil mobil, sehingga menyegarkan hasil penelusuran.
Saya memfaktorkan ulang kode pencarian frontend saya untuk menggunakan URL search params
dalam semua kasus, bukan localStorage
. Menyimpan permintaan pencarian di URL memiliki keuntungan seperti kemampuan untuk berbagi parameter pencarian dengan mudah dengan teman dan menekan backspace untuk kembali ke permintaan pencarian yang sama.
Seperti inilah tampilan kode saya sekarang. Ini mencerminkan beberapa pemfaktoran ulang: localStorage
-> URL search params
, tingkat zoom statis -> koordinat viewport
, dan 'klik untuk mencari' -> 'keluar dari kotak masukan untuk mencari'.
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 ) ;
} ;
Untuk mengakomodasi pemfaktoran ulang, Google Map
sekarang mengambil informasi yang diperlukan dari URL search params
dan mem-parsing koordinat viewport
untuk menentukan area mana yang akan ditampilkan di peta.
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 menyimpan data pengguna, mobil, dan lainnya di beberapa tabel dan menggabungkan tabel. Halaman frontend memiliki komponen yang menggabungkan data dari beberapa tabel. model associations
backend dan JBuilder
membantu mengirimkan informasi yang tepat ke setiap komponen sekaligus meminimalkan panggilan backend.
Sebagai contoh, halaman favorit mengambil semua favorit hanya untuk pengguna saat ini dan dengan cepat memuat data terkait.
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
kemudian membuat respons yang menyertakan info untuk setiap favorit dan untuk mobil yang dilampirkan ke favorit, beserta foto mobil dan info 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
Karena setiap info favorit menyertakan detail mobil dan host mobil yang terkait, komponen FavoriteIndexItem
yang merender setiap ubin mobil favorit tidak perlu mengambil datanya sendiri, melainkan mendapatkan informasi yang diperlukan melalui props sebagai favorite
dari induknya FavoritesPage
.
FavoritesPage/index.js
...
< div id = "favs-index-container" >
{ favorites && favorites . map ( ( favorite , idx ) => (
< FavoriteIndexItem key = { idx } favorite = { favorite } />
) ) }
</ div >
. . .
Hasilnya, Redux state
terlihat seperti ini:
Lihatlah file sumber untuk implementasi fitur penting lainnya:
Perbaikan yang akan datang meliputi: