Rails React استنساخ كامل لـ Turo
Caro هي نسخة كاملة من Turo، وهي خدمة تأجير سيارات من نظير إلى نظير ("Airbnb للسيارات").
يتميز Caro Places Search Box
الذي يقترحه Google تلقائيًا Google Maps
، مع وظيفة ديناميكية للبحث في فهرس السيارات استنادًا إلى viewport
Google Map
URL search parameters
الأخرى.
لدى المستخدمين وظائف CRUD من أجل:
يتمتع كارو أيضًا بمصادقة المستخدم الكاملة.
استكشاف ذلك؟ هنا!
أو شاهد سريعًا
جيثب
ينكدين
مَلَفّ
في كارو، يستطيع المستخدمون القيام بما يلي:
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 أو تكبيرها/تصغيرها يؤدي إلى إرسال طلب جلب سيارات عن طريق تغيير قيمة 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 له فوائد مثل القدرة على مشاركة معلمات البحث بسهولة مع الأصدقاء والضغط على مسافة للخلف للعودة إلى نفس استعلام البحث.
هذا ما يبدو عليه الكود الخاص بي الآن. يعكس هذا بعض عوامل إعادة البناء: 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
كما يلي:
ألقِ نظرة على الملفات المصدر لتنفيذ الميزات البارزة الأخرى:
تشمل التحسينات القادمة ما يلي: