Rails React โคลนเต็มสแต็กของ Turo
Caro เป็นโคลนเต็มรูปแบบของ Turo ซึ่งเป็นบริการเช่ารถแบบ peer-to-peer ('Airbnb สำหรับรถยนต์')
Caro นำเสนอ Places Search Box
ที่แนะนำอัตโนมัติของ Google และ Google Maps
พร้อมฟังก์ชันการทำงานแบบไดนามิกเพื่อค้นหาดัชนีรถยนต์ตาม viewport
Google Map
และ URL search parameters
อื่นๆ
ผู้ใช้มีฟังก์ชัน CRUD สำหรับ:
Caro ยังมีการตรวจสอบสิทธิ์ผู้ใช้เต็มรูปแบบ
สำรวจมัน? ที่นี่!
หรือดูด่วนๆ
Github
ลิงค์ดิน
ผลงาน
ใน 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
ซึ่งเป็นชุดของค่า lat/lng ที่แสดงถึง 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
จึงมีลักษณะดังนี้:
ดูไฟล์ต้นฉบับเพื่อใช้งานคุณสมบัติเด่นอื่นๆ:
การปรับปรุงที่กำลังจะเกิดขึ้น ได้แก่: