از صفر تا ویترین: سفر من در حال ساخت پلت فرم اجاره ملک

Summarize this content to 400 words in Persian Lang
مطالب
مقدمه
پشته فناوری
بررسی اجمالی سریع
API
Frontend
اپلیکیشن موبایل
داشبورد مدیریت
نقاط مورد علاقه
منابع
کد منبع: https://github.com/aelassas/movinin
نسخه ی نمایشی: https://movin.dynv6.net:3004
مقدمه
این ایده از تمایل به ساخت و ساز بدون مرز – یک پلت فرم اجاره ملک کاملاً قابل تنظیم و عملیاتی که در آن همه جنبه ها در کنترل شما است پدید آمد:
مالک UI/UX باشید: طراحی تجربیات منحصر به فرد مشتری بدون مبارزه با محدودیت های قالب
Backend را کنترل کنید: منطق تجاری و ساختارهای داده سفارشی را پیاده سازی کنید که کاملاً با الزامات مطابقت دارد
Master DevOps: استقرار، مقیاس و نظارت بر برنامه را با ابزارها و گردش کار ترجیحی
آزادانه گسترش دهید: بدون محدودیت های پلت فرم یا هزینه های اضافی، ویژگی ها و ادغام های جدید را اضافه کنید
پشته فناوری
در اینجا پشته فناوری است که این امکان را فراهم کرده است:
TypeScript
Node.js
MongoDB
واکنش نشان دهید
MUI
نمایشگاه
راه راه
داکر
یک تصمیم کلیدی طراحی برای استفاده از TypeScript به دلیل مزایای متعدد آن گرفته شد. TypeScript تایپ، ابزار و یکپارچه سازی قوی را ارائه می دهد که منجر به کدهای با کیفیت بالا، مقیاس پذیر، خواناتر و قابل نگهداری می شود که به راحتی اشکال زدایی و آزمایش می شود.
من React را برای قابلیتهای رندر قدرتمند، MongoDB برای مدلسازی دادههای انعطافپذیر و Stripe را برای پردازش پرداخت امن انتخاب کردم.
با انتخاب این پشته، شما فقط یک وبسایت و برنامه تلفن همراه نمیسازید، بلکه روی بنیادی سرمایهگذاری میکنید که میتواند با نیازهای شما تکامل یابد، با پشتیبانی از فناوریهای منبع باز قوی و جامعه توسعهدهندگان در حال رشد.
بررسی اجمالی سریع
در این بخش، صفحات اصلی frontend، داشبورد مدیریت و اپلیکیشن موبایل را مشاهده خواهید کرد.
Frontend
از قسمت جلو، مشتری می تواند املاک موجود را جستجو کند، ملکی را انتخاب کند و پرداخت کند.
در زیر صفحه اصلی صفحه اصلی است که در آن مشتری می تواند مکان و زمان را مشخص کند و املاک موجود را جستجو کند.
در زیر نتیجه جستجوی صفحه اصلی است که مشتری می تواند ملکی را برای اجاره انتخاب کند.
در زیر صفحه ای است که مشتری می تواند جزئیات ملک را مشاهده کند:
در زیر نمایی از تصاویر ملک را مشاهده می کنید:
در زیر صفحه پرداخت وجود دارد که در آن مشتری می تواند گزینه های اجاره و پرداخت را تعیین کند. در صورتی که مشتری ثبت نام نکرده باشد، می تواند همزمان تسویه حساب و ثبت نام کند. اگر هنوز ثبت نام نکرده باشد، یک ایمیل تایید و فعال سازی برای تعیین رمز عبور دریافت می کند.
در زیر صفحه ورود به سیستم است. در زمان تولید، کوکیهای احراز هویت httpOnly، امضا شده، امن و سختگیر در همان سایت هستند. این گزینه ها از حملات XSS، CSRF و MITM جلوگیری می کنند. کوکیهای احراز هویت از طریق یک میانافزار سفارشی نیز در برابر حملات XST محافظت میشوند.
در زیر صفحه ثبت نام است.
در زیر صفحه ای است که مشتری می تواند رزروهای خود را ببیند و مدیریت کند.
در زیر صفحهای است که مشتری میتواند رزرو را با جزئیات مشاهده کند.
در زیر صفحه ای است که مشتری می تواند اعلان های خود را ببیند.
در زیر صفحه ای است که مشتری می تواند تنظیمات خود را مدیریت کند.
در زیر صفحه ای وجود دارد که مشتری می تواند رمز عبور خود را تغییر دهد.
همین است. این صفحات اصلی صفحه اصلی است.
داشبورد مدیریت
سه نوع کاربر:
ادمین ها: آنها به داشبورد مدیریت دسترسی کامل دارند. آنها می توانند همه چیز را انجام دهند.
آژانس ها: دسترسی محدودی به داشبورد مدیریت دارند. آنها فقط می توانند املاک، رزروها و مشتریان خود را مدیریت کنند.
مشتریان: آنها فقط به قسمت ظاهری و اپلیکیشن موبایل دسترسی دارند. آنها نمی توانند به داشبورد مدیریت دسترسی داشته باشند.
این پلتفرم برای کار با چندین آژانس طراحی شده است. هر آژانس می تواند دارایی ها، مشتریان و رزروهای خود را از داشبورد مدیریت مدیریت کند. این پلتفرم همچنین می تواند تنها با یک آژانس نیز کار کند.
از باطن، ادمین ها می توانند آژانس ها، املاک، مکان ها، مشتریان و رزروها را ایجاد و مدیریت کنند.
وقتی آژانسهای جدید ایجاد میشوند، ایمیلی دریافت میکنند که از آنها میخواهد حساب خود را برای دسترسی به داشبورد مدیریت ایجاد کنند تا بتوانند داراییها، مشتریان و رزروهای خود را مدیریت کنند.
در زیر صفحه ورود به سیستم داشبورد مدیریت است.
در زیر صفحه داشبورد است که در آن مدیران و آژانسها میتوانند رزروها را ببینند و مدیریت کنند.
اگر وضعیت رزرو تغییر کند، مشتری مرتبط یک اعلان و یک ایمیل دریافت خواهد کرد.
در زیر صفحه ای است که در آن ویژگی ها نمایش داده می شوند و می توان آنها را مدیریت کرد.
در زیر صفحه ای است که در آن ادمین ها و آژانس ها می توانند با ارائه تصاویر و اطلاعات دارایی، ویژگی های جدید ایجاد کنند. برای لغو رایگان، آن را روی 0 قرار دهید. در غیر این صورت، قیمت گزینه را تعیین کنید یا اگر نمی خواهید آن را وارد کنید، آن را خالی بگذارید.
در زیر صفحهای است که مدیران و آژانسها میتوانند ویژگیها را ویرایش کنند.
در زیر صفحه ای است که ادمین ها می توانند مشتریان را مدیریت کنند.
در زیر صفحه ای است که اگر آژانس بخواهد از داشبورد مدیریت رزرو ایجاد کند، می توان رزرو کرد. در غیر این صورت، زمانی که فرآیند تسویه حساب از قسمت ظاهری یا برنامه تلفن همراه تکمیل شد، رزروها به طور خودکار ایجاد می شوند.
در زیر صفحه ای برای ویرایش رزرو وجود دارد.
در زیر صفحه ای است که در آن آژانس ها را مدیریت کنید.
در زیر صفحه ای برای ایجاد آژانس های جدید وجود دارد.
در زیر صفحه ای است که در آن آژانس ها را ویرایش کنید.
در زیر صفحه ای است که در آن می توان املاک آژانس ها را مشاهده کرد.
در زیر صفحه ای است که می توانید رزروهای مشتری را ببینید.
در زیر صفحه ای وجود دارد که ادمین ها و آژانس ها می توانند تنظیمات خود را مدیریت کنند.
صفحات دیگری نیز وجود دارد اما اینها صفحات اصلی داشبورد مدیریت هستند.
همین است. این صفحات اصلی داشبورد مدیریت است.
API
API تمام عملکردهای مورد نیاز برای داشبورد مدیریت، بخش ظاهری و اپلیکیشن موبایل را در معرض دید قرار می دهد. API از الگوی طراحی MVC پیروی می کند. JWT برای احراز هویت استفاده می شود. برخی از توابع مانند توابع مربوط به مدیریت دارایی ها، رزروها و مشتریان نیاز به احراز هویت دارند و برخی دیگر مانند بازیابی مکان ها و ویژگی های موجود برای کاربران غیر احراز هویت نیازی به احراز هویت ندارند:
پوشه ./api/src/models/ حاوی مدل های MongoDB است.
پوشه ./api/src/routes/ حاوی مسیرهای سریع است.
پوشه ./api/src/controllers/ حاوی کنترلرهایی است.
پوشه ./api/src/middlewares/ حاوی میان افزار است.
./api/src/config/env.config.ts شامل پیکربندی و تعاریف نوع TypeScript است.
پوشه ./api/src/lang/ حاوی محلی سازی است.
./api/src/app.ts سرور اصلی است که مسیرها در آن بارگیری می شوند.
./api/index.ts نقطه ورود اصلی API است.
index.ts نقطه ورود اصلی API است:
import ‘dotenv/config’
import process from ‘node:process’
import fs from ‘node:fs/promises’
import http from ‘node:http’
import https, { ServerOptions } from ‘node:https’
import app from ‘./app’
import * as databaseHelper from ‘./common/databaseHelper’
import * as env from ‘./config/env.config’
import * as logger from ‘./common/logger’
if (
await databaseHelper.connect(env.DB_URI, env.DB_SSL, env.DB_DEBUG)
&& await databaseHelper.initialize()
) {
let server: http.Server | https.Server
if (env.HTTPS) {
https.globalAgent.maxSockets = Number.POSITIVE_INFINITY
const privateKey = await fs.readFile(env.PRIVATE_KEY, ‘utf8’)
const certificate = await fs.readFile(env.CERTIFICATE, ‘utf8’)
const credentials: ServerOptions = { key: privateKey, cert: certificate }
server = https.createServer(credentials, app)
server.listen(env.PORT, () => {
logger.info(‘HTTPS server is running on Port’, env.PORT)
})
} else {
server = app.listen(env.PORT, () => {
logger.info(‘HTTP server is running on Port’, env.PORT)
})
}
const close = () => {
logger.info(‘Gracefully stopping…’)
server.close(async () => {
logger.info(`HTTP${env.HTTPS ? ‘S’ : ”} server closed`)
await databaseHelper.close(true)
logger.info(‘MongoDB connection closed’)
process.exit(0)
})
}
[‘SIGINT’, ‘SIGTERM’, ‘SIGQUIT’].forEach((signal) => process.on(signal, close))
}
این یک فایل TypeScript است که سرور را با استفاده از Node.js و Express راه اندازی می کند. چندین ماژول از جمله dotenv، process، fs، http، https، mongoose و app را وارد می کند. سپس بررسی می کند که آیا متغیر محیط HTTPS روی true تنظیم شده است یا خیر، و در این صورت، با استفاده از ماژول https و کلید خصوصی و گواهی ارائه شده، یک سرور HTTPS ایجاد می کند. در غیر این صورت با استفاده از ماژول http یک سرور HTTP ایجاد می کند. سرور به پورت مشخص شده در متغیر محیطی PORT گوش می دهد.
عملکرد بسته به گونه ای تعریف شده است که هنگام دریافت سیگنال خاتمه، سرور را به طرز دلپذیری متوقف کند. سرور و اتصال MongoDB را میبندد و سپس با کد وضعیت 0 از فرآیند خارج میشود. در نهایت، تابع بستن را ثبت میکند تا زمانی که فرآیند سیگنال SIGINT، SIGTERM یا SIGQUIT را دریافت میکند، فراخوانی شود.
app.ts نقطه ورود اصلی api است:
import express from ‘express’
import compression from ‘compression’
import helmet from ‘helmet’
import nocache from ‘nocache’
import cookieParser from ‘cookie-parser’
import i18n from ‘./lang/i18n’
import * as env from ‘./config/env.config’
import cors from ‘./middlewares/cors’
import allowedMethods from ‘./middlewares/allowedMethods’
import agencyRoutes from ‘./routes/agencyRoutes’
import bookingRoutes from ‘./routes/bookingRoutes’
import locationRoutes from ‘./routes/locationRoutes’
import notificationRoutes from ‘./routes/notificationRoutes’
import propertyRoutes from ‘./routes/propertyRoutes’
import userRoutes from ‘./routes/userRoutes’
import stripeRoutes from ‘./routes/stripeRoutes’
import countryRoutes from ‘./routes/countryRoutes’
import * as helper from ‘./common/helper’
const app = express()
app.use(helmet.contentSecurityPolicy())
app.use(helmet.dnsPrefetchControl())
app.use(helmet.crossOriginEmbedderPolicy())
app.use(helmet.frameguard())
app.use(helmet.hidePoweredBy())
app.use(helmet.hsts())
app.use(helmet.ieNoOpen())
app.use(helmet.noSniff())
app.use(helmet.permittedCrossDomainPolicies())
app.use(helmet.referrerPolicy())
app.use(helmet.xssFilter())
app.use(helmet.originAgentCluster())
app.use(helmet.crossOriginResourcePolicy({ policy: ‘cross-origin’ }))
app.use(helmet.crossOriginOpenerPolicy())
app.use(nocache())
app.use(compression({ threshold: 0 }))
app.use(express.urlencoded({ limit: ’50mb’, extended: true }))
app.use(express.json({ limit: ’50mb’ }))
app.use(cors())
app.options(‘*’, cors())
app.use(cookieParser(env.COOKIE_SECRET))
app.use(allowedMethods)
app.use(‘/’, agencyRoutes)
app.use(‘/’, bookingRoutes)
app.use(‘/’, locationRoutes)
app.use(‘/’, notificationRoutes)
app.use(‘/’, propertyRoutes)
app.use(‘/’, userRoutes)
app.use(‘/’, stripeRoutes)
app.use(‘/’, countryRoutes)
i18n.locale = env.DEFAULT_LANGUAGE
helper.mkdir(env.CDN_USERS)
helper.mkdir(env.CDN_TEMP_USERS)
helper.mkdir(env.CDN_PROPERTIES)
helper.mkdir(env.CDN_TEMP_PROPERTIES)
helper.mkdir(env.CDN_LOCATIONS)
helper.mkdir(env.CDN_TEMP_LOCATIONS)
export default app
اول از همه، رشته اتصال MongoDB را بازیابی می کنیم، سپس با پایگاه داده MongoDB ارتباط برقرار می کنیم. سپس یک برنامه Express ایجاد می کنیم و میان افزارهایی مانند cors، compression، helmet و nocache را بارگذاری می کنیم. ما اقدامات امنیتی مختلفی را با استفاده از کتابخانه میان افزار کلاه ایمنی تنظیم کردیم. ما همچنین فایلهای مسیرهای مختلف را برای بخشهای مختلف برنامه مانند providerRoutes، bookingRoutes، locationRoutes، notificationRoutes، milkRoutes و userRoutes وارد میکنیم. در نهایت، مسیرهای اکسپرس و برنامه صادرات را بارگیری می کنیم.
8 مسیر در API وجود دارد. هر مسیر دارای کنترلر مخصوص به خود است که از الگوی طراحی MVC و اصول SOLID پیروی می کند. در زیر مسیرهای اصلی آمده است:
userRoutes: توابع REST مربوط به کاربران را ارائه می دهد
AgencyRoutes: توابع REST مربوط به آژانس ها را ارائه می دهد
countryRoutes: توابع REST مربوط به کشورها را ارائه می دهد
locationRoutes: توابع REST مربوط به مکان ها را ارائه می دهد
PropertyRoutes: توابع REST مربوط به خواص را ارائه می دهد
bookingRoutes: عملکردهای REST مربوط به رزرو را ارائه می دهد
notificationRoutes: توابع REST مربوط به اعلان ها را ارائه می دهد
stripeRoutes: توابع REST مربوط به درگاه پرداخت Stripe را ارائه می دهد
قرار نیست هر مسیر را یکی یکی توضیح دهیم. به عنوان مثال PropertyRoutes را می گیریم و می بینیم که چگونه ساخته شده است. می توانید کد منبع را مرور کنید و همه مسیرها را ببینید.
اینجا milkRoutes.ts است:
import express from ‘express’
import multer from ‘multer’
import routeNames from ‘../config/propertyRoutes.config’
import authJwt from ‘../middlewares/authJwt’
import * as propertyController from ‘../controllers/propertyController’
const routes = express.Router()
routes.route(routeNames.create).post(authJwt.verifyToken, propertyController.create)
routes.route(routeNames.update).put(authJwt.verifyToken, propertyController.update)
routes.route(routeNames.checkProperty).get(authJwt.verifyToken, propertyController.checkProperty)
routes.route(routeNames.delete).delete(authJwt.verifyToken, propertyController.deleteProperty)
routes.route(routeNames.uploadImage).post([authJwt.verifyToken, multer({ storage: multer.memoryStorage() }).single(‘image’)], propertyController.uploadImage)
routes.route(routeNames.deleteImage).post(authJwt.verifyToken, propertyController.deleteImage)
routes.route(routeNames.deleteTempImage).post(authJwt.verifyToken, propertyController.deleteTempImage)
routes.route(routeNames.getProperty).get(propertyController.getProperty)
routes.route(routeNames.getProperties).post(authJwt.verifyToken, propertyController.getProperties)
routes.route(routeNames.getBookingProperties).post(authJwt.verifyToken, propertyController.getBookingProperties)
routes.route(routeNames.getFrontendProperties).post(propertyController.getFrontendProperties)
export default routes
اول از همه، ما یک روتر Express ایجاد می کنیم. سپس مسیرها را با استفاده از نام، متد، میان افزارها و کنترلرهای آنها ایجاد می کنیم.
routeNames حاوی ویژگی Routes نام مسیر است:
const routes = {
create: ‘/api/create-property’,
update: ‘/api/update-property’,
delete: ‘/api/delete-property/:id’,
uploadImage: ‘/api/upload-property-image’,
deleteTempImage: ‘/api/delete-temp-property-image/:fileName’,
deleteImage: ‘/api/delete-property-image/:property/:image’,
getProperty: ‘/api/property/:id/:language’,
getProperties: ‘/api/properties/:page/:size’,
getBookingProperties: ‘/api/booking-properties/:page/:size’,
getFrontendProperties: ‘/api/frontend-properties/:page/:size’,
checkProperty: ‘/api/check-property/:id’,
}
export default routes
PropertyController حاوی منطق اصلی کسب و کار در مورد مکان ها است. ما قصد نداریم تمام کد منبع کنترلر را ببینیم زیرا بسیار بزرگ است، اما برای مثال تابع ایجاد کنترلر را در نظر می گیریم.
مدل ملک در زیر آمده است:
import { Schema, model } from ‘mongoose’
import * as movininTypes from ‘:movinin-types’
import * as env from ‘../config/env.config’
const propertySchema = new Schema<env.Property>(
{
name: {
type: String,
required: [true, “can’t be blank”],
},
type: {
type: String,
enum: [
movininTypes.PropertyType.House,
movininTypes.PropertyType.Apartment,
movininTypes.PropertyType.Townhouse,
movininTypes.PropertyType.Plot,
movininTypes.PropertyType.Farm,
movininTypes.PropertyType.Commercial,
movininTypes.PropertyType.Industrial,
],
required: [true, “can’t be blank”],
},
agency: {
type: Schema.Types.ObjectId,
required: [true, “can’t be blank”],
ref: ‘User’,
index: true,
},
description: {
type: String,
required: [true, “can’t be blank”],
},
available: {
type: Boolean,
default: true,
},
image: {
type: String,
},
images: {
type: [String],
},
bedrooms: {
type: Number,
required: [true, “can’t be blank”],
validate: {
validator: Number.isInteger,
message: ‘{VALUE} is not an integer value’,
},
},
bathrooms: {
type: Number,
required: [true, “can’t be blank”],
validate: {
validator: Number.isInteger,
message: ‘{VALUE} is not an integer value’,
},
},
kitchens: {
type: Number,
default: 1,
validate: {
validator: Number.isInteger,
message: ‘{VALUE} is not an integer value’,
},
},
parkingSpaces: {
type: Number,
default: 0,
validate: {
validator: Number.isInteger,
message: ‘{VALUE} is not an integer value’,
},
},
size: {
type: Number,
},
petsAllowed: {
type: Boolean,
required: [true, “can’t be blank”],
},
furnished: {
type: Boolean,
required: [true, “can’t be blank”],
},
minimumAge: {
type: Number,
required: [true, “can’t be blank”],
min: env.MINIMUM_AGE,
max: 99,
},
location: {
type: Schema.Types.ObjectId,
ref: ‘Location’,
required: [true, “can’t be blank”],
},
address: {
type: String,
},
price: {
type: Number,
required: [true, “can’t be blank”],
},
hidden: {
type: Boolean,
default: false,
},
cancellation: {
type: Number,
default: 0,
},
aircon: {
type: Boolean,
default: false,
},
rentalTerm: {
type: String,
enum: [
movininTypes.RentalTerm.Monthly,
movininTypes.RentalTerm.Weekly,
movininTypes.RentalTerm.Daily,
movininTypes.RentalTerm.Yearly,
],
required: [true, “can’t be blank”],
},
},
{
timestamps: true,
strict: true,
collection: ‘Property’,
},
)
const Property = model<env.Property>(‘Property’, propertySchema)
export default Property
در زیر نوع ملک آمده است:
export interface Property extends Document {
name: string
type: movininTypes.PropertyType
agency: Types.ObjectId
description: string
image: string
images?: string[]
bedrooms: number
bathrooms: number
kitchens?: number
parkingSpaces?: number,
size?: number
petsAllowed: boolean
furnished: boolean
minimumAge: number
location: Types.ObjectId
address?: string
price: number
hidden?: boolean
cancellation?: number
aircon?: boolean
available?: boolean
rentalTerm: movininTypes.RentalTerm
}
یک ملک از موارد زیر تشکیل شده است:
یک نام
یک نوع (آپارتمان، تجاری، مزرعه، خانه، صنعتی، زمین، خانه شهری)
اشاره به آژانسی که آن را ایجاد کرده است
یک توضیح
یک تصویر اصلی
تصاویر اضافی
تعداد اتاق خواب
تعداد حمام
تعداد آشپزخانه
تعداد جای پارک
یک اندازه
حداقل سن برای اجاره
یک مکان
آدرس (اختیاری)
یک قیمت
مدت اجاره (ماهانه، هفتگی، روزانه، سالانه)
قیمت لغو (برای گنجاندن رایگان آن را روی 0 تنظیم کنید، اگر نمیخواهید آن را لحاظ کنید آن را خالی بگذارید، یا قیمت لغو را تنظیم کنید)
پرچمی که نشان می دهد حیوانات خانگی مجاز هستند یا نه
پرچمی که نشان دهنده مبله بودن یا نبودن ملک است
پرچمی که نشان می دهد ملک مخفی است یا خیر
پرچمی که نشان می دهد تهویه مطبوع موجود است یا نه
پرچمی که نشان می دهد ملک برای اجاره در دسترس است یا خیر
در زیر تابع ایجاد کنترلر است:
export const create = async (req: Request, res: Response) => {
const { body }: { body: movininTypes.CreatePropertyPayload } = req
try {
const {
name,
type,
agency,
description,
image: imageFile,
images,
bedrooms,
bathrooms,
kitchens,
parkingSpaces,
size,
petsAllowed,
furnished,
minimumAge,
location,
address,
price,
hidden,
cancellation,
aircon,
rentalTerm,
} = body
const _property = {
name,
type,
agency,
description,
bedrooms,
bathrooms,
kitchens,
parkingSpaces,
size,
petsAllowed,
furnished,
minimumAge,
location,
address,
price,
hidden,
cancellation,
aircon,
rentalTerm,
}
const property = new Property(_property)
await property.save()
// image
const _image = path.join(env.CDN_TEMP_PROPERTIES, imageFile)
if (await helper.exists(_image)) {
const filename = `${property._id}_${Date.now()}${path.extname(imageFile)}`
const newPath = path.join(env.CDN_PROPERTIES, filename)
await fs.rename(_image, newPath)
property.image = filename
} else {
await Property.deleteOne({ _id: property._id })
const err = ‘Image file not found’
logger.error(i18n.t(‘ERROR’), err)
return res.status(400).send(i18n.t(‘ERROR’) + err)
}
// images
property.images = []
if (images) {
let i = 1
for (const img of images) {
const _img = path.join(env.CDN_TEMP_PROPERTIES, img)
if (await helper.exists(_img)) {
const filename = `${property._id}_${uuid()}_${Date.now()}_${i}${path.extname(img)}`
const newPath = path.join(env.CDN_PROPERTIES, filename)
await fs.rename(_img, newPath)
property.images.push(filename)
} else {
await Property.deleteOne({ _id: property._id })
const err = ‘Image file not found’
logger.error(i18n.t(‘ERROR’), err)
return res.status(400).send(i18n.t(‘ERROR’) + err)
}
i += 1
}
}
await property.save()
return res.json(property)
} catch (err) {
logger.error(`[property.create] ${i18n.t(‘DB_ERROR’)} ${JSON.stringify(body)}`, err)
return res.status(400).send(i18n.t(‘ERROR’) + err)
}
}
Frontend
Frontend یک برنامه وب است که با Node.js، React، MUI و TypeScript ساخته شده است. از قسمت جلویی، مشتری میتواند بسته به نقاط تحویل و تحویل و زمان، خودروهای موجود را جستجو کند، خودرویی را انتخاب کرده و به تسویهحساب ادامه دهد:
پوشه ./frontend/src/assets/ حاوی CSS و تصاویر است.
پوشه ./frontend/src/pages/ حاوی صفحات React است.
پوشه ./frontend/src/components/ حاوی اجزای React است.
./frontend/src/services/ شامل خدمات کلاینت api است.
./frontend/src/App.tsx برنامه اصلی React است که حاوی مسیرها است.
./frontend/src/index.tsx نقطه ورود اصلی frontend است.
تعاریف نوع TypeScript در بسته ./packages/movinin-types تعریف شده است.
App.tsx اصلی ترین برنامه واکنش است:
import React, { lazy, Suspense } from ‘react’
import { BrowserRouter as Router, Route, Routes } from ‘react-router-dom’
import env from ‘@/config/env.config’
import { GlobalProvider } from ‘@/context/GlobalContext’
import { init as initGA } from ‘@/common/ga4’
if (env.GOOGLE_ANALYTICS_ENABLED) {
initGA()
}
const SignIn = lazy(() => import(‘@/pages/SignIn’))
const SignUp = lazy(() => import(‘@/pages/SignUp’))
const Activate = lazy(() => import(‘@/pages/Activate’))
const ForgotPassword = lazy(() => import(‘@/pages/ForgotPassword’))
const ResetPassword = lazy(() => import(‘@/pages/ResetPassword’))
const Home = lazy(() => import(‘@/pages/Home’))
const Search = lazy(() => import(‘@/pages/Search’))
const Property = lazy(() => import(‘@/pages/Property’))
const Checkout = lazy(() => import(‘@/pages/Checkout’))
const CheckoutSession = lazy(() => import(‘@/pages/CheckoutSession’))
const Bookings = lazy(() => import(‘@/pages/Bookings’))
const Booking = lazy(() => import(‘@/pages/Booking’))
const Settings = lazy(() => import(‘@/pages/Settings’))
const Notifications = lazy(() => import(‘@/pages/Notifications’))
const ToS = lazy(() => import(‘@/pages/ToS’))
const About = lazy(() => import(‘@/pages/About’))
const ChangePassword = lazy(() => import(‘@/pages/ChangePassword’))
const Contact = lazy(() => import(‘@/pages/Contact’))
const NoMatch = lazy(() => import(‘@/pages/NoMatch’))
const Agencies = lazy(() => import(‘@/pages/Agencies’))
const Locations = lazy(() => import(‘@/pages/Locations’))
const App = () => (
<GlobalProvider>
<Router>
<div className=”app”>
<Suspense fallback={<></>}>
<Routes>
<Route path=”/sign-in” element={<SignIn />} />
<Route path=”/sign-up” element={<SignUp />} />
<Route path=”/activate” element={<Activate />} />
<Route path=”/forgot-password” element={<ForgotPassword />} />
<Route path=”/reset-password” element={<ResetPassword />} />
<Route path=”/” element={<Home />} />
<Route path=”/search” element={<Search />} />
<Route path=”/property” element={<Property />} />
<Route path=”/checkout” element={<Checkout />} />
<Route path=”/checkout-session/:sessionId” element={<CheckoutSession />} />
<Route path=”/bookings” element={<Bookings />} />
<Route path=”/booking” element={<Booking />} />
<Route path=”/settings” element={<Settings />} />
<Route path=”/notifications” element={<Notifications />} />
<Route path=”/change-password” element={<ChangePassword />} />
<Route path=”/about” element={<About />} />
<Route path=”/tos” element={<ToS />} />
<Route path=”/contact” element={<Contact />} />
<Route path=”/agencies” element={<Agencies />} />
<Route path=”/destinations” element={<Locations />} />
<Route path=”*” element={<NoMatch />} />
</Routes>
</Suspense>
</div>
</Router>
</GlobalProvider>
)
export default App
ما از React Lazy loading برای بارگذاری هر مسیر استفاده می کنیم.
ما قرار نیست هر صفحه از صفحه اصلی را پوشش دهیم، اما می توانید کد منبع را مرور کنید و هر کدام را ببینید.
اپلیکیشن موبایل
این پلتفرم یک اپلیکیشن موبایلی بومی برای اندروید و iOS ارائه می کند. اپلیکیشن موبایل با React Native، Expo و TypeScript ساخته شده است. مانند قسمت جلویی، اپلیکیشن موبایل به مشتری این امکان را میدهد که خودروهای موجود را بسته به نقاط تحویل و تحویل و زمان جستجو کند، خودرویی را انتخاب کند و به تسویهحساب ادامه دهد.
اگر رزرو او از قسمت باطن بهروزرسانی شود، مشتری اعلانهای فشاری دریافت میکند. اعلانهای فشاری با Node.js، Expo Server SDK و Firebase ساخته میشوند.
پوشه ./mobile/assets/ حاوی تصاویر است.
پوشه ./mobile/screens/ حاوی صفحات اصلی React Native است.
پوشه ./mobile/components/ حاوی اجزای React Native است.
./mobile/services/ شامل خدمات کلاینت api است.
./mobile/App.tsx برنامه اصلی React Native است.
تعاریف نوع TypeScript در موارد زیر تعریف می شوند:
./mobile/types/index.d.ts
./mobile/types/env.d.ts
./mobile/miscellaneous/movinTypes.ts
./mobile/types/ به صورت زیر در ./mobile/tsconfig.json بارگیری می شود:
{
“extends”: “expo/tsconfig.base”,
“compilerOptions”: {
“strict”: true,
“typeRoots”: [
“./types”
]
}
}
App.tsx نقطه ورود اصلی برنامه React Native است:
import ‘react-native-gesture-handler’
import React, { useCallback, useEffect, useRef, useState } from ‘react’
import { RootSiblingParent } from ‘react-native-root-siblings’
import { NavigationContainer, NavigationContainerRef } from ‘@react-navigation/native’
import { StatusBar as ExpoStatusBar } from ‘expo-status-bar’
import { SafeAreaProvider } from ‘react-native-safe-area-context’
import { Provider } from ‘react-native-paper’
import * as SplashScreen from ‘expo-splash-screen’
import * as Notifications from ‘expo-notifications’
import { StripeProvider } from ‘@stripe/stripe-react-native’
import DrawerNavigator from ‘./components/DrawerNavigator’
import * as helper from ‘./common/helper’
import * as NotificationService from ‘./services/NotificationService’
import * as UserService from ‘./services/UserService’
import { GlobalProvider } from ‘./context/GlobalContext’
import * as env from ‘./config/env.config’
Notifications.setNotificationHandler({
handleNotification: async () => ({
shouldShowAlert: true,
shouldPlaySound: true,
shouldSetBadge: true,
}),
})
//
// Prevent native splash screen from autohiding before App component declaration
//
SplashScreen.preventAutoHideAsync()
.then((result) => console.log(`SplashScreen.preventAutoHideAsync() succeeded: ${result}`))
.catch(console.warn) // it’s good to explicitly catch and inspect any error
const App = () => {
const [appIsReady, setAppIsReady] = useState(false)
const responseListener = useRef<Notifications.Subscription>()
const navigationRef = useRef<NavigationContainerRef<StackParams>>(null)
useEffect(() => {
const register = async () => {
const loggedIn = await UserService.loggedIn()
if (loggedIn) {
const currentUser = await UserService.getCurrentUser()
if (currentUser?._id) {
await helper.registerPushToken(currentUser._id)
} else {
helper.error()
}
}
}
//
// Register push notifiations token
//
register()
//
// This listener is fired whenever a user taps on or interacts with a notification (works when app is foregrounded, backgrounded, or killed)
//
responseListener.current = Notifications.addNotificationResponseReceivedListener(async (response) => {
try {
if (navigationRef.current) {
const { data } = response.notification.request.content
if (data.booking) {
if (data.user && data.notification) {
await NotificationService.markAsRead(data.user, [data.notification])
}
navigationRef.current.navigate(‘Booking’, { id: data.booking })
} else {
navigationRef.current.navigate(‘Notifications’, {})
}
}
} catch (err) {
helper.error(err, false)
}
})
return () => {
Notifications.removeNotificationSubscription(responseListener.current!)
}
}, [])
setTimeout(() => {
setAppIsReady(true)
}, 500)
const onReady = useCallback(async () => {
if (appIsReady) {
//
// This tells the splash screen to hide immediately! If we call this after
// `setAppIsReady`, then we may see a blank screen while the app is
// loading its initial state and rendering its first pixels. So instead,
// we hide the splash screen once we know the root view has already
// performed layout.
//
await SplashScreen.hideAsync()
}
}, [appIsReady])
if (!appIsReady) {
return null
}
return (
<GlobalProvider>
<SafeAreaProvider>
<Provider>
<StripeProvider publishableKey={env.STRIPE_PUBLISHABLE_KEY} merchantIdentifier={env.STRIPE_MERCHANT_IDENTIFIER}>
<RootSiblingParent>
<NavigationContainer ref={navigationRef} onReady={onReady}>
<ExpoStatusBar style=”light” backgroundColor=”rgba(0, 0, 0, .9)” />
<DrawerNavigator />
</NavigationContainer>
</RootSiblingParent>
</StripeProvider>
</Provider>
</SafeAreaProvider>
</GlobalProvider>
)
}
export default App
ما قرار نیست هر صفحه برنامه تلفن همراه را پوشش دهیم، اما می توانید کد منبع را مرور کنید و هر کدام را ببینید.
داشبورد مدیریت
داشبورد مدیریت یک برنامه وب است که با Node.js، React، MUI و TypeScript ساخته شده است. از باطن، ادمین ها می توانند تامین کنندگان، ماشین ها، مکان ها، مشتریان و رزروها را ایجاد و مدیریت کنند. هنگامی که تامین کنندگان جدید از باطن ایجاد می شوند، ایمیلی دریافت می کنند که از آنها می خواهد یک حساب کاربری ایجاد کنند تا به داشبورد مدیریت دسترسی داشته باشند و ناوگان خودرو و رزروهای خود را مدیریت کنند.
پوشه ./backend/assets/ حاوی CSS و تصاویر است.
پوشه ./backend/pages/ حاوی صفحات React است.
پوشه ./backend/components/ حاوی اجزای React است.
./backend/services/ شامل خدمات کلاینت api است.
./backend/App.tsx برنامه اصلی React است که حاوی مسیرها است.
./backend/index.tsx نقطه ورود اصلی داشبورد مدیریت است.
تعاریف نوع TypeScript در بسته ./packages/movinin-types تعریف شده است.
App.tsx داشبورد مدیریت از منطق مشابهی مانند App.tsx قسمت جلو پیروی می کند.
ما قرار نیست هر صفحه از داشبورد مدیریت را پوشش دهیم، اما می توانید کد منبع را مرور کنید و هر کدام را ببینید.
نقاط مورد علاقه
ساخت اپلیکیشن موبایل با React Native و Expo بسیار آسان است. Expo توسعه موبایل با React Native را بسیار ساده می کند.
استفاده از همان زبان (TypeScript) برای توسعه Backend، Frontend و موبایل بسیار راحت است.
TypeScript زبان بسیار جالبی است و مزایای زیادی دارد. با افزودن تایپ استاتیک به جاوا اسکریپت، میتوانیم از بسیاری از باگها جلوگیری کنیم و کدی با کیفیت بالا، مقیاسپذیر، خواناتر و قابل نگهداری تولید کنیم که به راحتی اشکال زدایی و آزمایش شود.
همین! امیدوارم از خواندن این مقاله لذت برده باشید.
منابع
نمای کلی
معماری
در حال نصب (خود میزبان)
نصب (VPS)
نصب (Docker)
تصویر داکر
SSL
Stripe را راه اندازی کنید
ساخت اپلیکیشن موبایل
پایگاه داده نسخه ی نمایشی
ویندوز، لینوکس و macOS
داکر
از منبع اجرا شود
برنامه موبایل را اجرا کنید
پیش نیازها
دستورالعمل ها
Push Notifications
تغییر ارز
زبان جدید اضافه کنید
تست های واحد و پوشش
سیاههها
مطالب
- مقدمه
- پشته فناوری
- بررسی اجمالی سریع
- API
- Frontend
- اپلیکیشن موبایل
- داشبورد مدیریت
- نقاط مورد علاقه
- منابع
کد منبع: https://github.com/aelassas/movinin
نسخه ی نمایشی: https://movin.dynv6.net:3004
مقدمه
این ایده از تمایل به ساخت و ساز بدون مرز – یک پلت فرم اجاره ملک کاملاً قابل تنظیم و عملیاتی که در آن همه جنبه ها در کنترل شما است پدید آمد:
- مالک UI/UX باشید: طراحی تجربیات منحصر به فرد مشتری بدون مبارزه با محدودیت های قالب
- Backend را کنترل کنید: منطق تجاری و ساختارهای داده سفارشی را پیاده سازی کنید که کاملاً با الزامات مطابقت دارد
- Master DevOps: استقرار، مقیاس و نظارت بر برنامه را با ابزارها و گردش کار ترجیحی
- آزادانه گسترش دهید: بدون محدودیت های پلت فرم یا هزینه های اضافی، ویژگی ها و ادغام های جدید را اضافه کنید
پشته فناوری
در اینجا پشته فناوری است که این امکان را فراهم کرده است:
- TypeScript
- Node.js
- MongoDB
- واکنش نشان دهید
- MUI
- نمایشگاه
- راه راه
- داکر
یک تصمیم کلیدی طراحی برای استفاده از TypeScript به دلیل مزایای متعدد آن گرفته شد. TypeScript تایپ، ابزار و یکپارچه سازی قوی را ارائه می دهد که منجر به کدهای با کیفیت بالا، مقیاس پذیر، خواناتر و قابل نگهداری می شود که به راحتی اشکال زدایی و آزمایش می شود.
من React را برای قابلیتهای رندر قدرتمند، MongoDB برای مدلسازی دادههای انعطافپذیر و Stripe را برای پردازش پرداخت امن انتخاب کردم.
با انتخاب این پشته، شما فقط یک وبسایت و برنامه تلفن همراه نمیسازید، بلکه روی بنیادی سرمایهگذاری میکنید که میتواند با نیازهای شما تکامل یابد، با پشتیبانی از فناوریهای منبع باز قوی و جامعه توسعهدهندگان در حال رشد.
بررسی اجمالی سریع
در این بخش، صفحات اصلی frontend، داشبورد مدیریت و اپلیکیشن موبایل را مشاهده خواهید کرد.
Frontend
از قسمت جلو، مشتری می تواند املاک موجود را جستجو کند، ملکی را انتخاب کند و پرداخت کند.
در زیر صفحه اصلی صفحه اصلی است که در آن مشتری می تواند مکان و زمان را مشخص کند و املاک موجود را جستجو کند.
در زیر نتیجه جستجوی صفحه اصلی است که مشتری می تواند ملکی را برای اجاره انتخاب کند.
در زیر صفحه ای است که مشتری می تواند جزئیات ملک را مشاهده کند:
در زیر نمایی از تصاویر ملک را مشاهده می کنید:
در زیر صفحه پرداخت وجود دارد که در آن مشتری می تواند گزینه های اجاره و پرداخت را تعیین کند. در صورتی که مشتری ثبت نام نکرده باشد، می تواند همزمان تسویه حساب و ثبت نام کند. اگر هنوز ثبت نام نکرده باشد، یک ایمیل تایید و فعال سازی برای تعیین رمز عبور دریافت می کند.
در زیر صفحه ورود به سیستم است. در زمان تولید، کوکیهای احراز هویت httpOnly، امضا شده، امن و سختگیر در همان سایت هستند. این گزینه ها از حملات XSS، CSRF و MITM جلوگیری می کنند. کوکیهای احراز هویت از طریق یک میانافزار سفارشی نیز در برابر حملات XST محافظت میشوند.
در زیر صفحه ثبت نام است.
در زیر صفحه ای است که مشتری می تواند رزروهای خود را ببیند و مدیریت کند.
در زیر صفحهای است که مشتری میتواند رزرو را با جزئیات مشاهده کند.
در زیر صفحه ای است که مشتری می تواند اعلان های خود را ببیند.
در زیر صفحه ای است که مشتری می تواند تنظیمات خود را مدیریت کند.
در زیر صفحه ای وجود دارد که مشتری می تواند رمز عبور خود را تغییر دهد.
همین است. این صفحات اصلی صفحه اصلی است.
داشبورد مدیریت
سه نوع کاربر:
- ادمین ها: آنها به داشبورد مدیریت دسترسی کامل دارند. آنها می توانند همه چیز را انجام دهند.
- آژانس ها: دسترسی محدودی به داشبورد مدیریت دارند. آنها فقط می توانند املاک، رزروها و مشتریان خود را مدیریت کنند.
- مشتریان: آنها فقط به قسمت ظاهری و اپلیکیشن موبایل دسترسی دارند. آنها نمی توانند به داشبورد مدیریت دسترسی داشته باشند.
این پلتفرم برای کار با چندین آژانس طراحی شده است. هر آژانس می تواند دارایی ها، مشتریان و رزروهای خود را از داشبورد مدیریت مدیریت کند. این پلتفرم همچنین می تواند تنها با یک آژانس نیز کار کند.
از باطن، ادمین ها می توانند آژانس ها، املاک، مکان ها، مشتریان و رزروها را ایجاد و مدیریت کنند.
وقتی آژانسهای جدید ایجاد میشوند، ایمیلی دریافت میکنند که از آنها میخواهد حساب خود را برای دسترسی به داشبورد مدیریت ایجاد کنند تا بتوانند داراییها، مشتریان و رزروهای خود را مدیریت کنند.
در زیر صفحه ورود به سیستم داشبورد مدیریت است.
در زیر صفحه داشبورد است که در آن مدیران و آژانسها میتوانند رزروها را ببینند و مدیریت کنند.
اگر وضعیت رزرو تغییر کند، مشتری مرتبط یک اعلان و یک ایمیل دریافت خواهد کرد.
در زیر صفحه ای است که در آن ویژگی ها نمایش داده می شوند و می توان آنها را مدیریت کرد.
در زیر صفحه ای است که در آن ادمین ها و آژانس ها می توانند با ارائه تصاویر و اطلاعات دارایی، ویژگی های جدید ایجاد کنند. برای لغو رایگان، آن را روی 0 قرار دهید. در غیر این صورت، قیمت گزینه را تعیین کنید یا اگر نمی خواهید آن را وارد کنید، آن را خالی بگذارید.
در زیر صفحهای است که مدیران و آژانسها میتوانند ویژگیها را ویرایش کنند.
در زیر صفحه ای است که ادمین ها می توانند مشتریان را مدیریت کنند.
در زیر صفحه ای است که اگر آژانس بخواهد از داشبورد مدیریت رزرو ایجاد کند، می توان رزرو کرد. در غیر این صورت، زمانی که فرآیند تسویه حساب از قسمت ظاهری یا برنامه تلفن همراه تکمیل شد، رزروها به طور خودکار ایجاد می شوند.
در زیر صفحه ای برای ویرایش رزرو وجود دارد.
در زیر صفحه ای است که در آن آژانس ها را مدیریت کنید.
در زیر صفحه ای برای ایجاد آژانس های جدید وجود دارد.
در زیر صفحه ای است که در آن آژانس ها را ویرایش کنید.
در زیر صفحه ای است که در آن می توان املاک آژانس ها را مشاهده کرد.
در زیر صفحه ای است که می توانید رزروهای مشتری را ببینید.
در زیر صفحه ای وجود دارد که ادمین ها و آژانس ها می توانند تنظیمات خود را مدیریت کنند.
صفحات دیگری نیز وجود دارد اما اینها صفحات اصلی داشبورد مدیریت هستند.
همین است. این صفحات اصلی داشبورد مدیریت است.
API
API تمام عملکردهای مورد نیاز برای داشبورد مدیریت، بخش ظاهری و اپلیکیشن موبایل را در معرض دید قرار می دهد. API از الگوی طراحی MVC پیروی می کند. JWT برای احراز هویت استفاده می شود. برخی از توابع مانند توابع مربوط به مدیریت دارایی ها، رزروها و مشتریان نیاز به احراز هویت دارند و برخی دیگر مانند بازیابی مکان ها و ویژگی های موجود برای کاربران غیر احراز هویت نیازی به احراز هویت ندارند:
- پوشه ./api/src/models/ حاوی مدل های MongoDB است.
- پوشه ./api/src/routes/ حاوی مسیرهای سریع است.
- پوشه ./api/src/controllers/ حاوی کنترلرهایی است.
- پوشه ./api/src/middlewares/ حاوی میان افزار است.
- ./api/src/config/env.config.ts شامل پیکربندی و تعاریف نوع TypeScript است.
- پوشه ./api/src/lang/ حاوی محلی سازی است.
- ./api/src/app.ts سرور اصلی است که مسیرها در آن بارگیری می شوند.
- ./api/index.ts نقطه ورود اصلی API است.
index.ts نقطه ورود اصلی API است:
import 'dotenv/config'
import process from 'node:process'
import fs from 'node:fs/promises'
import http from 'node:http'
import https, { ServerOptions } from 'node:https'
import app from './app'
import * as databaseHelper from './common/databaseHelper'
import * as env from './config/env.config'
import * as logger from './common/logger'
if (
await databaseHelper.connect(env.DB_URI, env.DB_SSL, env.DB_DEBUG)
&& await databaseHelper.initialize()
) {
let server: http.Server | https.Server
if (env.HTTPS) {
https.globalAgent.maxSockets = Number.POSITIVE_INFINITY
const privateKey = await fs.readFile(env.PRIVATE_KEY, 'utf8')
const certificate = await fs.readFile(env.CERTIFICATE, 'utf8')
const credentials: ServerOptions = { key: privateKey, cert: certificate }
server = https.createServer(credentials, app)
server.listen(env.PORT, () => {
logger.info('HTTPS server is running on Port', env.PORT)
})
} else {
server = app.listen(env.PORT, () => {
logger.info('HTTP server is running on Port', env.PORT)
})
}
const close = () => {
logger.info('Gracefully stopping...')
server.close(async () => {
logger.info(`HTTP${env.HTTPS ? 'S' : ''} server closed`)
await databaseHelper.close(true)
logger.info('MongoDB connection closed')
process.exit(0)
})
}
['SIGINT', 'SIGTERM', 'SIGQUIT'].forEach((signal) => process.on(signal, close))
}
این یک فایل TypeScript است که سرور را با استفاده از Node.js و Express راه اندازی می کند. چندین ماژول از جمله dotenv، process، fs، http، https، mongoose و app را وارد می کند. سپس بررسی می کند که آیا متغیر محیط HTTPS روی true تنظیم شده است یا خیر، و در این صورت، با استفاده از ماژول https و کلید خصوصی و گواهی ارائه شده، یک سرور HTTPS ایجاد می کند. در غیر این صورت با استفاده از ماژول http یک سرور HTTP ایجاد می کند. سرور به پورت مشخص شده در متغیر محیطی PORT گوش می دهد.
عملکرد بسته به گونه ای تعریف شده است که هنگام دریافت سیگنال خاتمه، سرور را به طرز دلپذیری متوقف کند. سرور و اتصال MongoDB را میبندد و سپس با کد وضعیت 0 از فرآیند خارج میشود. در نهایت، تابع بستن را ثبت میکند تا زمانی که فرآیند سیگنال SIGINT، SIGTERM یا SIGQUIT را دریافت میکند، فراخوانی شود.
app.ts نقطه ورود اصلی api است:
import express from 'express'
import compression from 'compression'
import helmet from 'helmet'
import nocache from 'nocache'
import cookieParser from 'cookie-parser'
import i18n from './lang/i18n'
import * as env from './config/env.config'
import cors from './middlewares/cors'
import allowedMethods from './middlewares/allowedMethods'
import agencyRoutes from './routes/agencyRoutes'
import bookingRoutes from './routes/bookingRoutes'
import locationRoutes from './routes/locationRoutes'
import notificationRoutes from './routes/notificationRoutes'
import propertyRoutes from './routes/propertyRoutes'
import userRoutes from './routes/userRoutes'
import stripeRoutes from './routes/stripeRoutes'
import countryRoutes from './routes/countryRoutes'
import * as helper from './common/helper'
const app = express()
app.use(helmet.contentSecurityPolicy())
app.use(helmet.dnsPrefetchControl())
app.use(helmet.crossOriginEmbedderPolicy())
app.use(helmet.frameguard())
app.use(helmet.hidePoweredBy())
app.use(helmet.hsts())
app.use(helmet.ieNoOpen())
app.use(helmet.noSniff())
app.use(helmet.permittedCrossDomainPolicies())
app.use(helmet.referrerPolicy())
app.use(helmet.xssFilter())
app.use(helmet.originAgentCluster())
app.use(helmet.crossOriginResourcePolicy({ policy: 'cross-origin' }))
app.use(helmet.crossOriginOpenerPolicy())
app.use(nocache())
app.use(compression({ threshold: 0 }))
app.use(express.urlencoded({ limit: '50mb', extended: true }))
app.use(express.json({ limit: '50mb' }))
app.use(cors())
app.options('*', cors())
app.use(cookieParser(env.COOKIE_SECRET))
app.use(allowedMethods)
app.use('/', agencyRoutes)
app.use('/', bookingRoutes)
app.use('/', locationRoutes)
app.use('/', notificationRoutes)
app.use('/', propertyRoutes)
app.use('/', userRoutes)
app.use('/', stripeRoutes)
app.use('/', countryRoutes)
i18n.locale = env.DEFAULT_LANGUAGE
helper.mkdir(env.CDN_USERS)
helper.mkdir(env.CDN_TEMP_USERS)
helper.mkdir(env.CDN_PROPERTIES)
helper.mkdir(env.CDN_TEMP_PROPERTIES)
helper.mkdir(env.CDN_LOCATIONS)
helper.mkdir(env.CDN_TEMP_LOCATIONS)
export default app
اول از همه، رشته اتصال MongoDB را بازیابی می کنیم، سپس با پایگاه داده MongoDB ارتباط برقرار می کنیم. سپس یک برنامه Express ایجاد می کنیم و میان افزارهایی مانند cors، compression، helmet و nocache را بارگذاری می کنیم. ما اقدامات امنیتی مختلفی را با استفاده از کتابخانه میان افزار کلاه ایمنی تنظیم کردیم. ما همچنین فایلهای مسیرهای مختلف را برای بخشهای مختلف برنامه مانند providerRoutes، bookingRoutes، locationRoutes، notificationRoutes، milkRoutes و userRoutes وارد میکنیم. در نهایت، مسیرهای اکسپرس و برنامه صادرات را بارگیری می کنیم.
8 مسیر در API وجود دارد. هر مسیر دارای کنترلر مخصوص به خود است که از الگوی طراحی MVC و اصول SOLID پیروی می کند. در زیر مسیرهای اصلی آمده است:
- userRoutes: توابع REST مربوط به کاربران را ارائه می دهد
- AgencyRoutes: توابع REST مربوط به آژانس ها را ارائه می دهد
- countryRoutes: توابع REST مربوط به کشورها را ارائه می دهد
- locationRoutes: توابع REST مربوط به مکان ها را ارائه می دهد
- PropertyRoutes: توابع REST مربوط به خواص را ارائه می دهد
- bookingRoutes: عملکردهای REST مربوط به رزرو را ارائه می دهد
- notificationRoutes: توابع REST مربوط به اعلان ها را ارائه می دهد
- stripeRoutes: توابع REST مربوط به درگاه پرداخت Stripe را ارائه می دهد
قرار نیست هر مسیر را یکی یکی توضیح دهیم. به عنوان مثال PropertyRoutes را می گیریم و می بینیم که چگونه ساخته شده است. می توانید کد منبع را مرور کنید و همه مسیرها را ببینید.
اینجا milkRoutes.ts است:
import express from 'express'
import multer from 'multer'
import routeNames from '../config/propertyRoutes.config'
import authJwt from '../middlewares/authJwt'
import * as propertyController from '../controllers/propertyController'
const routes = express.Router()
routes.route(routeNames.create).post(authJwt.verifyToken, propertyController.create)
routes.route(routeNames.update).put(authJwt.verifyToken, propertyController.update)
routes.route(routeNames.checkProperty).get(authJwt.verifyToken, propertyController.checkProperty)
routes.route(routeNames.delete).delete(authJwt.verifyToken, propertyController.deleteProperty)
routes.route(routeNames.uploadImage).post([authJwt.verifyToken, multer({ storage: multer.memoryStorage() }).single('image')], propertyController.uploadImage)
routes.route(routeNames.deleteImage).post(authJwt.verifyToken, propertyController.deleteImage)
routes.route(routeNames.deleteTempImage).post(authJwt.verifyToken, propertyController.deleteTempImage)
routes.route(routeNames.getProperty).get(propertyController.getProperty)
routes.route(routeNames.getProperties).post(authJwt.verifyToken, propertyController.getProperties)
routes.route(routeNames.getBookingProperties).post(authJwt.verifyToken, propertyController.getBookingProperties)
routes.route(routeNames.getFrontendProperties).post(propertyController.getFrontendProperties)
export default routes
اول از همه، ما یک روتر Express ایجاد می کنیم. سپس مسیرها را با استفاده از نام، متد، میان افزارها و کنترلرهای آنها ایجاد می کنیم.
routeNames حاوی ویژگی Routes نام مسیر است:
const routes = {
create: '/api/create-property',
update: '/api/update-property',
delete: '/api/delete-property/:id',
uploadImage: '/api/upload-property-image',
deleteTempImage: '/api/delete-temp-property-image/:fileName',
deleteImage: '/api/delete-property-image/:property/:image',
getProperty: '/api/property/:id/:language',
getProperties: '/api/properties/:page/:size',
getBookingProperties: '/api/booking-properties/:page/:size',
getFrontendProperties: '/api/frontend-properties/:page/:size',
checkProperty: '/api/check-property/:id',
}
export default routes
PropertyController حاوی منطق اصلی کسب و کار در مورد مکان ها است. ما قصد نداریم تمام کد منبع کنترلر را ببینیم زیرا بسیار بزرگ است، اما برای مثال تابع ایجاد کنترلر را در نظر می گیریم.
مدل ملک در زیر آمده است:
import { Schema, model } from 'mongoose'
import * as movininTypes from ':movinin-types'
import * as env from '../config/env.config'
const propertySchema = new Schema<env.Property>(
{
name: {
type: String,
required: [true, "can't be blank"],
},
type: {
type: String,
enum: [
movininTypes.PropertyType.House,
movininTypes.PropertyType.Apartment,
movininTypes.PropertyType.Townhouse,
movininTypes.PropertyType.Plot,
movininTypes.PropertyType.Farm,
movininTypes.PropertyType.Commercial,
movininTypes.PropertyType.Industrial,
],
required: [true, "can't be blank"],
},
agency: {
type: Schema.Types.ObjectId,
required: [true, "can't be blank"],
ref: 'User',
index: true,
},
description: {
type: String,
required: [true, "can't be blank"],
},
available: {
type: Boolean,
default: true,
},
image: {
type: String,
},
images: {
type: [String],
},
bedrooms: {
type: Number,
required: [true, "can't be blank"],
validate: {
validator: Number.isInteger,
message: '{VALUE} is not an integer value',
},
},
bathrooms: {
type: Number,
required: [true, "can't be blank"],
validate: {
validator: Number.isInteger,
message: '{VALUE} is not an integer value',
},
},
kitchens: {
type: Number,
default: 1,
validate: {
validator: Number.isInteger,
message: '{VALUE} is not an integer value',
},
},
parkingSpaces: {
type: Number,
default: 0,
validate: {
validator: Number.isInteger,
message: '{VALUE} is not an integer value',
},
},
size: {
type: Number,
},
petsAllowed: {
type: Boolean,
required: [true, "can't be blank"],
},
furnished: {
type: Boolean,
required: [true, "can't be blank"],
},
minimumAge: {
type: Number,
required: [true, "can't be blank"],
min: env.MINIMUM_AGE,
max: 99,
},
location: {
type: Schema.Types.ObjectId,
ref: 'Location',
required: [true, "can't be blank"],
},
address: {
type: String,
},
price: {
type: Number,
required: [true, "can't be blank"],
},
hidden: {
type: Boolean,
default: false,
},
cancellation: {
type: Number,
default: 0,
},
aircon: {
type: Boolean,
default: false,
},
rentalTerm: {
type: String,
enum: [
movininTypes.RentalTerm.Monthly,
movininTypes.RentalTerm.Weekly,
movininTypes.RentalTerm.Daily,
movininTypes.RentalTerm.Yearly,
],
required: [true, "can't be blank"],
},
},
{
timestamps: true,
strict: true,
collection: 'Property',
},
)
const Property = model<env.Property>('Property', propertySchema)
export default Property
در زیر نوع ملک آمده است:
export interface Property extends Document {
name: string
type: movininTypes.PropertyType
agency: Types.ObjectId
description: string
image: string
images?: string[]
bedrooms: number
bathrooms: number
kitchens?: number
parkingSpaces?: number,
size?: number
petsAllowed: boolean
furnished: boolean
minimumAge: number
location: Types.ObjectId
address?: string
price: number
hidden?: boolean
cancellation?: number
aircon?: boolean
available?: boolean
rentalTerm: movininTypes.RentalTerm
}
یک ملک از موارد زیر تشکیل شده است:
- یک نام
- یک نوع (آپارتمان، تجاری، مزرعه، خانه، صنعتی، زمین، خانه شهری)
- اشاره به آژانسی که آن را ایجاد کرده است
- یک توضیح
- یک تصویر اصلی
- تصاویر اضافی
- تعداد اتاق خواب
- تعداد حمام
- تعداد آشپزخانه
- تعداد جای پارک
- یک اندازه
- حداقل سن برای اجاره
- یک مکان
- آدرس (اختیاری)
- یک قیمت
- مدت اجاره (ماهانه، هفتگی، روزانه، سالانه)
- قیمت لغو (برای گنجاندن رایگان آن را روی 0 تنظیم کنید، اگر نمیخواهید آن را لحاظ کنید آن را خالی بگذارید، یا قیمت لغو را تنظیم کنید)
- پرچمی که نشان می دهد حیوانات خانگی مجاز هستند یا نه
- پرچمی که نشان دهنده مبله بودن یا نبودن ملک است
- پرچمی که نشان می دهد ملک مخفی است یا خیر
- پرچمی که نشان می دهد تهویه مطبوع موجود است یا نه
- پرچمی که نشان می دهد ملک برای اجاره در دسترس است یا خیر
در زیر تابع ایجاد کنترلر است:
export const create = async (req: Request, res: Response) => {
const { body }: { body: movininTypes.CreatePropertyPayload } = req
try {
const {
name,
type,
agency,
description,
image: imageFile,
images,
bedrooms,
bathrooms,
kitchens,
parkingSpaces,
size,
petsAllowed,
furnished,
minimumAge,
location,
address,
price,
hidden,
cancellation,
aircon,
rentalTerm,
} = body
const _property = {
name,
type,
agency,
description,
bedrooms,
bathrooms,
kitchens,
parkingSpaces,
size,
petsAllowed,
furnished,
minimumAge,
location,
address,
price,
hidden,
cancellation,
aircon,
rentalTerm,
}
const property = new Property(_property)
await property.save()
// image
const _image = path.join(env.CDN_TEMP_PROPERTIES, imageFile)
if (await helper.exists(_image)) {
const filename = `${property._id}_${Date.now()}${path.extname(imageFile)}`
const newPath = path.join(env.CDN_PROPERTIES, filename)
await fs.rename(_image, newPath)
property.image = filename
} else {
await Property.deleteOne({ _id: property._id })
const err = 'Image file not found'
logger.error(i18n.t('ERROR'), err)
return res.status(400).send(i18n.t('ERROR') + err)
}
// images
property.images = []
if (images) {
let i = 1
for (const img of images) {
const _img = path.join(env.CDN_TEMP_PROPERTIES, img)
if (await helper.exists(_img)) {
const filename = `${property._id}_${uuid()}_${Date.now()}_${i}${path.extname(img)}`
const newPath = path.join(env.CDN_PROPERTIES, filename)
await fs.rename(_img, newPath)
property.images.push(filename)
} else {
await Property.deleteOne({ _id: property._id })
const err = 'Image file not found'
logger.error(i18n.t('ERROR'), err)
return res.status(400).send(i18n.t('ERROR') + err)
}
i += 1
}
}
await property.save()
return res.json(property)
} catch (err) {
logger.error(`[property.create] ${i18n.t('DB_ERROR')} ${JSON.stringify(body)}`, err)
return res.status(400).send(i18n.t('ERROR') + err)
}
}
Frontend
Frontend یک برنامه وب است که با Node.js، React، MUI و TypeScript ساخته شده است. از قسمت جلویی، مشتری میتواند بسته به نقاط تحویل و تحویل و زمان، خودروهای موجود را جستجو کند، خودرویی را انتخاب کرده و به تسویهحساب ادامه دهد:
- پوشه ./frontend/src/assets/ حاوی CSS و تصاویر است.
- پوشه ./frontend/src/pages/ حاوی صفحات React است.
- پوشه ./frontend/src/components/ حاوی اجزای React است.
- ./frontend/src/services/ شامل خدمات کلاینت api است.
- ./frontend/src/App.tsx برنامه اصلی React است که حاوی مسیرها است.
- ./frontend/src/index.tsx نقطه ورود اصلی frontend است.
تعاریف نوع TypeScript در بسته ./packages/movinin-types تعریف شده است.
App.tsx اصلی ترین برنامه واکنش است:
import React, { lazy, Suspense } from 'react'
import { BrowserRouter as Router, Route, Routes } from 'react-router-dom'
import env from '@/config/env.config'
import { GlobalProvider } from '@/context/GlobalContext'
import { init as initGA } from '@/common/ga4'
if (env.GOOGLE_ANALYTICS_ENABLED) {
initGA()
}
const SignIn = lazy(() => import('@/pages/SignIn'))
const SignUp = lazy(() => import('@/pages/SignUp'))
const Activate = lazy(() => import('@/pages/Activate'))
const ForgotPassword = lazy(() => import('@/pages/ForgotPassword'))
const ResetPassword = lazy(() => import('@/pages/ResetPassword'))
const Home = lazy(() => import('@/pages/Home'))
const Search = lazy(() => import('@/pages/Search'))
const Property = lazy(() => import('@/pages/Property'))
const Checkout = lazy(() => import('@/pages/Checkout'))
const CheckoutSession = lazy(() => import('@/pages/CheckoutSession'))
const Bookings = lazy(() => import('@/pages/Bookings'))
const Booking = lazy(() => import('@/pages/Booking'))
const Settings = lazy(() => import('@/pages/Settings'))
const Notifications = lazy(() => import('@/pages/Notifications'))
const ToS = lazy(() => import('@/pages/ToS'))
const About = lazy(() => import('@/pages/About'))
const ChangePassword = lazy(() => import('@/pages/ChangePassword'))
const Contact = lazy(() => import('@/pages/Contact'))
const NoMatch = lazy(() => import('@/pages/NoMatch'))
const Agencies = lazy(() => import('@/pages/Agencies'))
const Locations = lazy(() => import('@/pages/Locations'))
const App = () => (
<GlobalProvider>
<Router>
<div className="app">
<Suspense fallback={<></>}>
<Routes>
<Route path="/sign-in" element={<SignIn />} />
<Route path="/sign-up" element={<SignUp />} />
<Route path="/activate" element={<Activate />} />
<Route path="/forgot-password" element={<ForgotPassword />} />
<Route path="/reset-password" element={<ResetPassword />} />
<Route path="/" element={<Home />} />
<Route path="/search" element={<Search />} />
<Route path="/property" element={<Property />} />
<Route path="/checkout" element={<Checkout />} />
<Route path="/checkout-session/:sessionId" element={<CheckoutSession />} />
<Route path="/bookings" element={<Bookings />} />
<Route path="/booking" element={<Booking />} />
<Route path="/settings" element={<Settings />} />
<Route path="/notifications" element={<Notifications />} />
<Route path="/change-password" element={<ChangePassword />} />
<Route path="/about" element={<About />} />
<Route path="/tos" element={<ToS />} />
<Route path="/contact" element={<Contact />} />
<Route path="/agencies" element={<Agencies />} />
<Route path="/destinations" element={<Locations />} />
<Route path="*" element={<NoMatch />} />
</Routes>
</Suspense>
</div>
</Router>
</GlobalProvider>
)
export default App
ما از React Lazy loading برای بارگذاری هر مسیر استفاده می کنیم.
ما قرار نیست هر صفحه از صفحه اصلی را پوشش دهیم، اما می توانید کد منبع را مرور کنید و هر کدام را ببینید.
اپلیکیشن موبایل
این پلتفرم یک اپلیکیشن موبایلی بومی برای اندروید و iOS ارائه می کند. اپلیکیشن موبایل با React Native، Expo و TypeScript ساخته شده است. مانند قسمت جلویی، اپلیکیشن موبایل به مشتری این امکان را میدهد که خودروهای موجود را بسته به نقاط تحویل و تحویل و زمان جستجو کند، خودرویی را انتخاب کند و به تسویهحساب ادامه دهد.
اگر رزرو او از قسمت باطن بهروزرسانی شود، مشتری اعلانهای فشاری دریافت میکند. اعلانهای فشاری با Node.js، Expo Server SDK و Firebase ساخته میشوند.
- پوشه ./mobile/assets/ حاوی تصاویر است.
- پوشه ./mobile/screens/ حاوی صفحات اصلی React Native است.
- پوشه ./mobile/components/ حاوی اجزای React Native است.
- ./mobile/services/ شامل خدمات کلاینت api است.
- ./mobile/App.tsx برنامه اصلی React Native است.
تعاریف نوع TypeScript در موارد زیر تعریف می شوند:
- ./mobile/types/index.d.ts
- ./mobile/types/env.d.ts
- ./mobile/miscellaneous/movinTypes.ts
./mobile/types/ به صورت زیر در ./mobile/tsconfig.json بارگیری می شود:
{
"extends": "expo/tsconfig.base",
"compilerOptions": {
"strict": true,
"typeRoots": [
"./types"
]
}
}
App.tsx نقطه ورود اصلی برنامه React Native است:
import 'react-native-gesture-handler'
import React, { useCallback, useEffect, useRef, useState } from 'react'
import { RootSiblingParent } from 'react-native-root-siblings'
import { NavigationContainer, NavigationContainerRef } from '@react-navigation/native'
import { StatusBar as ExpoStatusBar } from 'expo-status-bar'
import { SafeAreaProvider } from 'react-native-safe-area-context'
import { Provider } from 'react-native-paper'
import * as SplashScreen from 'expo-splash-screen'
import * as Notifications from 'expo-notifications'
import { StripeProvider } from '@stripe/stripe-react-native'
import DrawerNavigator from './components/DrawerNavigator'
import * as helper from './common/helper'
import * as NotificationService from './services/NotificationService'
import * as UserService from './services/UserService'
import { GlobalProvider } from './context/GlobalContext'
import * as env from './config/env.config'
Notifications.setNotificationHandler({
handleNotification: async () => ({
shouldShowAlert: true,
shouldPlaySound: true,
shouldSetBadge: true,
}),
})
//
// Prevent native splash screen from autohiding before App component declaration
//
SplashScreen.preventAutoHideAsync()
.then((result) => console.log(`SplashScreen.preventAutoHideAsync() succeeded: ${result}`))
.catch(console.warn) // it's good to explicitly catch and inspect any error
const App = () => {
const [appIsReady, setAppIsReady] = useState(false)
const responseListener = useRef<Notifications.Subscription>()
const navigationRef = useRef<NavigationContainerRef<StackParams>>(null)
useEffect(() => {
const register = async () => {
const loggedIn = await UserService.loggedIn()
if (loggedIn) {
const currentUser = await UserService.getCurrentUser()
if (currentUser?._id) {
await helper.registerPushToken(currentUser._id)
} else {
helper.error()
}
}
}
//
// Register push notifiations token
//
register()
//
// This listener is fired whenever a user taps on or interacts with a notification (works when app is foregrounded, backgrounded, or killed)
//
responseListener.current = Notifications.addNotificationResponseReceivedListener(async (response) => {
try {
if (navigationRef.current) {
const { data } = response.notification.request.content
if (data.booking) {
if (data.user && data.notification) {
await NotificationService.markAsRead(data.user, [data.notification])
}
navigationRef.current.navigate('Booking', { id: data.booking })
} else {
navigationRef.current.navigate('Notifications', {})
}
}
} catch (err) {
helper.error(err, false)
}
})
return () => {
Notifications.removeNotificationSubscription(responseListener.current!)
}
}, [])
setTimeout(() => {
setAppIsReady(true)
}, 500)
const onReady = useCallback(async () => {
if (appIsReady) {
//
// This tells the splash screen to hide immediately! If we call this after
// `setAppIsReady`, then we may see a blank screen while the app is
// loading its initial state and rendering its first pixels. So instead,
// we hide the splash screen once we know the root view has already
// performed layout.
//
await SplashScreen.hideAsync()
}
}, [appIsReady])
if (!appIsReady) {
return null
}
return (
<GlobalProvider>
<SafeAreaProvider>
<Provider>
<StripeProvider publishableKey={env.STRIPE_PUBLISHABLE_KEY} merchantIdentifier={env.STRIPE_MERCHANT_IDENTIFIER}>
<RootSiblingParent>
<NavigationContainer ref={navigationRef} onReady={onReady}>
<ExpoStatusBar style="light" backgroundColor="rgba(0, 0, 0, .9)" />
<DrawerNavigator />
</NavigationContainer>
</RootSiblingParent>
</StripeProvider>
</Provider>
</SafeAreaProvider>
</GlobalProvider>
)
}
export default App
ما قرار نیست هر صفحه برنامه تلفن همراه را پوشش دهیم، اما می توانید کد منبع را مرور کنید و هر کدام را ببینید.
داشبورد مدیریت
داشبورد مدیریت یک برنامه وب است که با Node.js، React، MUI و TypeScript ساخته شده است. از باطن، ادمین ها می توانند تامین کنندگان، ماشین ها، مکان ها، مشتریان و رزروها را ایجاد و مدیریت کنند. هنگامی که تامین کنندگان جدید از باطن ایجاد می شوند، ایمیلی دریافت می کنند که از آنها می خواهد یک حساب کاربری ایجاد کنند تا به داشبورد مدیریت دسترسی داشته باشند و ناوگان خودرو و رزروهای خود را مدیریت کنند.
- پوشه ./backend/assets/ حاوی CSS و تصاویر است.
- پوشه ./backend/pages/ حاوی صفحات React است.
- پوشه ./backend/components/ حاوی اجزای React است.
- ./backend/services/ شامل خدمات کلاینت api است.
- ./backend/App.tsx برنامه اصلی React است که حاوی مسیرها است.
- ./backend/index.tsx نقطه ورود اصلی داشبورد مدیریت است.
تعاریف نوع TypeScript در بسته ./packages/movinin-types تعریف شده است.
App.tsx داشبورد مدیریت از منطق مشابهی مانند App.tsx قسمت جلو پیروی می کند.
ما قرار نیست هر صفحه از داشبورد مدیریت را پوشش دهیم، اما می توانید کد منبع را مرور کنید و هر کدام را ببینید.
نقاط مورد علاقه
ساخت اپلیکیشن موبایل با React Native و Expo بسیار آسان است. Expo توسعه موبایل با React Native را بسیار ساده می کند.
استفاده از همان زبان (TypeScript) برای توسعه Backend، Frontend و موبایل بسیار راحت است.
TypeScript زبان بسیار جالبی است و مزایای زیادی دارد. با افزودن تایپ استاتیک به جاوا اسکریپت، میتوانیم از بسیاری از باگها جلوگیری کنیم و کدی با کیفیت بالا، مقیاسپذیر، خواناتر و قابل نگهداری تولید کنیم که به راحتی اشکال زدایی و آزمایش شود.
همین! امیدوارم از خواندن این مقاله لذت برده باشید.
منابع
- نمای کلی
- معماری
- در حال نصب (خود میزبان)
- نصب (VPS)
-
نصب (Docker)
- تصویر داکر
- SSL
- Stripe را راه اندازی کنید
- ساخت اپلیکیشن موبایل
-
پایگاه داده نسخه ی نمایشی
- ویندوز، لینوکس و macOS
- داکر
- از منبع اجرا شود
-
برنامه موبایل را اجرا کنید
- پیش نیازها
- دستورالعمل ها
- Push Notifications
- تغییر ارز
- زبان جدید اضافه کنید
- تست های واحد و پوشش
- سیاههها