install packages
13
.prettierignore
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
# Ignore build output
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
|
||||||
|
# Ignore dependencies
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# Ignore lock files
|
||||||
|
package-lock.json
|
||||||
|
yarn.lock
|
||||||
|
|
||||||
|
coverage/
|
||||||
|
*.min.js
|
||||||
14
.prettierrc
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"plugins": ["prettier-plugin-tailwindcss"],
|
||||||
|
|
||||||
|
"printWidth": 120,
|
||||||
|
"tabWidth": 4,
|
||||||
|
"useTabs": false,
|
||||||
|
"semi": true,
|
||||||
|
"singleQuote": false,
|
||||||
|
"trailingComma": "es5",
|
||||||
|
"bracketSpacing": true,
|
||||||
|
"jsxBracketSameLine": false,
|
||||||
|
"arrowParens": "always",
|
||||||
|
"endOfLine": "crlf"
|
||||||
|
}
|
||||||
50
Dockerfile
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
# ---------------------
|
||||||
|
# Base (PNPM + Node)
|
||||||
|
# ---------------------
|
||||||
|
FROM docker.arvancloud.ir/node:22-alpine AS base
|
||||||
|
RUN apk add --no-cache libc6-compat
|
||||||
|
WORKDIR /app
|
||||||
|
RUN npm install -g pnpm@10.8.0
|
||||||
|
|
||||||
|
# ---------------------
|
||||||
|
# Dependencies
|
||||||
|
# ---------------------
|
||||||
|
FROM base AS deps
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json pnpm-lock.yaml ./
|
||||||
|
RUN pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
# ---------------------
|
||||||
|
# Builder
|
||||||
|
# ---------------------
|
||||||
|
FROM base AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
|
ENV NEXT_PRIVATE_STANDALONE=true
|
||||||
|
|
||||||
|
RUN pnpm build
|
||||||
|
|
||||||
|
# ---------------------
|
||||||
|
# Runner
|
||||||
|
# ---------------------
|
||||||
|
FROM docker.arvancloud.ir/node:22-alpine AS runner
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
ENV PORT=3000
|
||||||
|
|
||||||
|
RUN addgroup --system --gid 1001 nodejs && \
|
||||||
|
adduser --system --uid 1001 nextjs
|
||||||
|
|
||||||
|
COPY --from=builder /app/public ./public
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||||
|
|
||||||
|
USER nextjs
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
CMD ["node", "server.js"]
|
||||||
16
docker-compose.yml
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
services:
|
||||||
|
salonro-front:
|
||||||
|
image: salonro-front:${VERSION}
|
||||||
|
container_name: salonro-front
|
||||||
|
# build: #just if we have internet uncomment
|
||||||
|
# context: .
|
||||||
|
# dockerfile: Dockerfile
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
restart: unless-stopped
|
||||||
|
# volumes:
|
||||||
|
# - .:/app # کل پروژه mount شده به /app در کانتینر
|
||||||
|
# - /app/node_modules # node_modules داخل کانتینر بماند تا با host override نشود
|
||||||
|
# - /app/.next # next داخل کانتینر بماند تا با host override نشود
|
||||||
@@ -5,6 +5,11 @@ import nextTs from "eslint-config-next/typescript";
|
|||||||
const eslintConfig = defineConfig([
|
const eslintConfig = defineConfig([
|
||||||
...nextVitals,
|
...nextVitals,
|
||||||
...nextTs,
|
...nextTs,
|
||||||
|
{
|
||||||
|
rules: {
|
||||||
|
"@typescript-eslint/no-explicit-any": "off",
|
||||||
|
},
|
||||||
|
},
|
||||||
// Override default ignores of eslint-config-next.
|
// Override default ignores of eslint-config-next.
|
||||||
globalIgnores([
|
globalIgnores([
|
||||||
// Default ignores of eslint-config-next:
|
// Default ignores of eslint-config-next:
|
||||||
|
|||||||
360
messages/fa.json
Normal file
@@ -0,0 +1,360 @@
|
|||||||
|
{
|
||||||
|
"loading": "درحال دریافت داده",
|
||||||
|
"error_fetch_data": "مشکلی در دریافت داده رخ داده است.",
|
||||||
|
"Header": {},
|
||||||
|
"HomePage": {
|
||||||
|
"title": "سلام",
|
||||||
|
"about141": "درباره 141",
|
||||||
|
"blog": "وبلاگ",
|
||||||
|
"searchThisArea": "جست و جو در این محدوده",
|
||||||
|
"allRightsReserved": "تمامی حقوق این وبسایت متعلق به مرکز مدیریت راههای کشور میباشد."
|
||||||
|
},
|
||||||
|
"Application": {
|
||||||
|
"title": "دانلود نرم افزار تلفن همراه 141",
|
||||||
|
"description": "نسخهی تلفن همراه اپلیکیشن مرکز مدیریت راههای کشور با قابلیتهایی نظیر نمایش تصاویر دوربینهای، نظارتی در سراسر کشور، مسیریابی همراه با نمایش مسافت و زمان تقریبی و جزئیات مسیر، مشاهده اخبار متنی و شنیداری، کروکی ترافیک استانهای کشور، دریافت اطلاعیههای مربوط به مسیرهای پرتردد و ... آماده دریافت از مارکتهای مختلف میباشد.",
|
||||||
|
"features": [
|
||||||
|
{
|
||||||
|
"step": "گام 1",
|
||||||
|
"title": "سریعتر بسازید",
|
||||||
|
"content": "MVP خود را در کمترین زمان ممکن با بلوکها و کامپوننتهای از پیش ساخته شده بسازید."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"step": "گام 2",
|
||||||
|
"title": "به راحتی شخصیسازی کنید",
|
||||||
|
"content": "هر کامپوننت را با سیستم طراحی شهودی و معماری منعطف ما مطابق نیازتان تغییر دهید."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"step": "گام 3",
|
||||||
|
"title": "با اعتماد منتشر کنید",
|
||||||
|
"content": "محصول خود را با اطمینان با استفاده از کامپوننتهای بهینه، واکنشگرا و دسترسپذیر منتشر کنید."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"step": "گام 4",
|
||||||
|
"title": "شما هم اضافه کنید!",
|
||||||
|
"content": "بلوکهای خود را اضافه کنید و بخشی از جامعه MVPBlocks شوید."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"AboutUsPage": {
|
||||||
|
"page_title": "شناسایی سریع وقایع جادهای، نقش اصلی مرکز مدیریت راههای کشور",
|
||||||
|
"page_subtitle": "اطلاعات بیشتر درباره ۱۴۱",
|
||||||
|
"history_title": "تاریخچه فعالیتها",
|
||||||
|
"history_text": "مركز مديريت راههای کشور از آذرماه سال ۸۷ با هدف بهينهسازی بهرهبرداری از راههای كشور و كاهش تأخير در جريان ترافيك جادهای شكل گرفت. اين مركز كه سالها عنوان \"اطلاعات راهها\" را يدك ميكشيد، مسئوليت اطلاعرسانی از وضعيت راههای كشور را بر عهده داشت. از سال ۸۵ حركتی تازه در زمينه شكلگيری مركز مديريت اطلاعات راهها و نوينسازی سامانهها آغاز شد و در ساختمان شماره ۳ سازمان راهداری و حمل و نقل جادهای مستقر گرديد. هدف اوليه شكلگيری مركز، توسعه فناوریها و مديريت اطلاعات ترافيك بود، نه مديريت عمليات جادهای.",
|
||||||
|
"detection_title": "نحوه شناسایی وقایع جادهای در ۱۴۱",
|
||||||
|
"detection_intro": "شناسايی سريع وقايع جادهای از فعاليتهای اساسی مركز مديريت راهها است. اين فرآيند از طريق روشهای مختلف زير انجام میشود:",
|
||||||
|
"detection_methods": {
|
||||||
|
"traditional": "شناسايی با روشهای معمول و عرفی",
|
||||||
|
"visual_tools": "شناسايی به كمك ابزار تصويری هوشمند",
|
||||||
|
"online_systems": "شناسايی با استفاده از نتايج برخط سامانههای هوشمند"
|
||||||
|
},
|
||||||
|
"traditional_detection_title": "شناسايی سريعتر وقايع جادهای با روشهای معمولی",
|
||||||
|
"traditional_detection_text": "از جمله روشهای معمول شناسايی وقايع جادهای میتوان به تماس كاربران جادهای، رانندگان حرفهای، گشتهای راه، و دريافت گزارش از ارگانهای ذیربط اشاره كرد. ارتباط منظم ميان شعبههای مركز مديريت راهها و ارگانهای استانی نقش مهمی در تسريع اطلاعرسانی دارد.",
|
||||||
|
"smart_detection_title": "شناسايی سريعتر وقايع جادهای با استفاده از سامانههای هوشمند",
|
||||||
|
"smart_detection_text": "استفاده از دوربينهای نظارتی هوشمند، دادههای برخط ترافيك شمارها و موقعيتياب GPS ماشينآلات، امكان شناسايی خودكار انسدادهای جادهای و تخمين زمان سفر را فراهم میكند.",
|
||||||
|
"emergency_contacts_title": "تلفن تماسهای اضطراری",
|
||||||
|
"emergency_contacts": {
|
||||||
|
"road_info": "اطلاع رسانی راههای کشور",
|
||||||
|
"emergency": "سازمان اورژانس کشور",
|
||||||
|
"police": "پلیس",
|
||||||
|
"fire_department": "آتش نشانی",
|
||||||
|
"intel": "ستاد خبری وزارت اطلاعات",
|
||||||
|
"toll_payment": "پرداخت آسان عوارض آزادراهی",
|
||||||
|
"ikco_assistance": "امداد خودرو ایران خودرو",
|
||||||
|
"saipa_assistance": "امداد خودرو سایپا",
|
||||||
|
"imam_airport": "اطلاعات پرواز فرودگاه امام خمینی",
|
||||||
|
"red_crescent": "ندای امداد جمعیت هلال احمر",
|
||||||
|
"meteorology": "سازمان هواشناسی",
|
||||||
|
"travel_services": "ستاد مرکزی هماهنگی خدمات سفر",
|
||||||
|
"railways": "اطلاعات راه آهن",
|
||||||
|
"flights": "اطلاعات پروازهای کشور"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"GeolocationError": {
|
||||||
|
"permission_denied": "اجازه دسترسی به لوکیشن وجود ندارد",
|
||||||
|
"position_unavailable": "مکان فعلی یافت نشد",
|
||||||
|
"timeout": "دوباره تلاش کنید",
|
||||||
|
"unknown_error": "خطای ناشناخته"
|
||||||
|
},
|
||||||
|
"ObstructionList": {
|
||||||
|
"search": "اعمال فیلتر",
|
||||||
|
"obstruction_cause": "علت انسداد: ",
|
||||||
|
"show_map": "نمایش نقشه",
|
||||||
|
"news_text": "متن خبر",
|
||||||
|
"direction": "جهت: ",
|
||||||
|
"forward": "مسیر رفت",
|
||||||
|
"backward": "مسیر برگشت",
|
||||||
|
"both": "هردو جهت",
|
||||||
|
"no_data": "موردی یافت نشد..."
|
||||||
|
},
|
||||||
|
"LimitationList": {
|
||||||
|
"search": "اعمال فیلتر",
|
||||||
|
"obstruction_cause": "علت انسداد: ",
|
||||||
|
"show_map": "نمایش نقشه",
|
||||||
|
"news_text": "متن خبر",
|
||||||
|
"close": "بستن",
|
||||||
|
"direction": "جهت: ",
|
||||||
|
"limitation_type": "نوع وسیله نقلیه: ",
|
||||||
|
"forward": "مسیر رفت",
|
||||||
|
"backward": "مسیر برگشت",
|
||||||
|
"both": "هردو جهت",
|
||||||
|
"no_data": "موردی یافت نشد..."
|
||||||
|
},
|
||||||
|
"RoadWorkList": {
|
||||||
|
"search": "اعمال فیلتر",
|
||||||
|
"obstruction_cause": "علت انسداد: ",
|
||||||
|
"show_map": "نمایش نقشه",
|
||||||
|
"news_text": "متن خبر",
|
||||||
|
"close": "بستن",
|
||||||
|
"direction": "جهت: ",
|
||||||
|
"limitation_type": "نوع وسیله نقلیه: ",
|
||||||
|
"forward": "مسیر رفت",
|
||||||
|
"backward": "مسیر برگشت",
|
||||||
|
"both": "هردو جهت",
|
||||||
|
"no_data": "موردی یافت نشد..."
|
||||||
|
},
|
||||||
|
"RoadWeather": {
|
||||||
|
"description": "توضیحات: ",
|
||||||
|
"risk_type": "نوع خطر: ",
|
||||||
|
"affected_region": "منطقه تحت تاثیر: ",
|
||||||
|
"start_date": "تاریخ شروع: ",
|
||||||
|
"updated_at": "آخرین بروزرسانی: ",
|
||||||
|
"end_date": "تاریخ پایان: "
|
||||||
|
},
|
||||||
|
"CustomLayouts": {
|
||||||
|
"road_cameras_title": "تصاویر دوربین های جاده ای",
|
||||||
|
"news_title": "اخبار",
|
||||||
|
"favorites": "مورد علاقه ها",
|
||||||
|
"profile": "جزییات حساب کاربری",
|
||||||
|
"road-weather": "اطلاعیه\u200Cهای هواشناسی جاده\u200Cای",
|
||||||
|
"limitations": "محدودیت های تردد",
|
||||||
|
"obstruction-list": "لیست انسداد ها",
|
||||||
|
"latest-roads-state": "آخرین وضعیت راه های کشور",
|
||||||
|
"road-works": "کارگاه جاده ای",
|
||||||
|
"corridors": "لیست کریدورها",
|
||||||
|
"police-traffic-restrictions": "\nمحدودیت\u200Cهای اعلامی پلیس راه",
|
||||||
|
"title": "لیست تصاویر"
|
||||||
|
},
|
||||||
|
"RoadCameras": {
|
||||||
|
"road_cameras_title": "تصاویر دوربین های جاده ای",
|
||||||
|
"news_title": "اخبار",
|
||||||
|
"title": "لیست تصاویر",
|
||||||
|
"select_province": "انتخاب استان",
|
||||||
|
"route": "مسیر",
|
||||||
|
"search": "جستجو",
|
||||||
|
"from": "مبدا",
|
||||||
|
"to": "مقصد",
|
||||||
|
"kilometer": "کیلومتر",
|
||||||
|
"minute": "دقیقه",
|
||||||
|
"hour": "ساعت",
|
||||||
|
"show_roads_on_map": "نمایش روی نقشه",
|
||||||
|
"distance": "فاصله",
|
||||||
|
"duration": "زمان",
|
||||||
|
"footer": "تمامی حقوق این وبسایت متعلق به مرکز مدیریت راه های کشور میباشد."
|
||||||
|
},
|
||||||
|
"Notifications": {
|
||||||
|
"code": "کد",
|
||||||
|
"error": "خطا",
|
||||||
|
"title": "درخواست شما ارسال شد",
|
||||||
|
"warning": "خطا",
|
||||||
|
"success": "موفق",
|
||||||
|
"error_static_text": "عملیات شما با خطا مواجه شد",
|
||||||
|
"warning_static_text": "خطا سرور",
|
||||||
|
"success_static_text": "عملیات شما با موفقیت انجام شد",
|
||||||
|
"pending": "در حال انجام..."
|
||||||
|
},
|
||||||
|
"mapServices": {
|
||||||
|
"obstructions": "انسدادها",
|
||||||
|
"cameras": "دوربینها",
|
||||||
|
"map_services/cameras": "دوربینها",
|
||||||
|
"no_data_for_service": "دادهای در این ناحیه یافت نشد",
|
||||||
|
"petrol_stations": "جایگاه های سوخت",
|
||||||
|
"car_assistances": "امداد فنی خودرو",
|
||||||
|
"map_services/weather_forecasts": "پیش بینی وضعیت آب و هوا",
|
||||||
|
"weather": "وضعیت آب و هوا",
|
||||||
|
"welfare_services": "مجتمع های خدمات رفاهی",
|
||||||
|
"mosques": "مساجد",
|
||||||
|
"hadde_tarakhos": "حد ترخص",
|
||||||
|
"tollhouses": "راهدارخانه",
|
||||||
|
"repair_shops": "تعمیرگاه",
|
||||||
|
"hospitals": "مراکز درمانی",
|
||||||
|
"accidents": "تصادفات",
|
||||||
|
"road_workshops": "کارگاه جاده ای",
|
||||||
|
"otfs": "ترددشمار",
|
||||||
|
"tolls": "عوارضی آزادراه",
|
||||||
|
"active_services": "سرویس های فعال"
|
||||||
|
},
|
||||||
|
"Routing": {
|
||||||
|
"routing": "مسیریابی",
|
||||||
|
"origin": "مبدا",
|
||||||
|
"destination": "مقصد",
|
||||||
|
"sourcePlaceholder": "مبدأ را وارد کنید",
|
||||||
|
"routingError": "خطا در مسیریابی",
|
||||||
|
"searchDestinationOrChooseOnMap": "مقصد را جستجو کنید یا روی نقشه کلیک کنید",
|
||||||
|
"searchOriginOrChooseOnMap": "مبدا را جستجو کنید یا روی نقشه کلیک کنید",
|
||||||
|
"loading": "درحال بارگذاری...",
|
||||||
|
"noSearchResults": "نتیجهای یافت نشد...",
|
||||||
|
"useMyLocation": "استفاده از موقعیت من",
|
||||||
|
"addDestination": "افزودن مقصد جدید"
|
||||||
|
},
|
||||||
|
"RoutingPreviewBox": {
|
||||||
|
"route": "مسیر {index} ",
|
||||||
|
"minute": "دقیقه",
|
||||||
|
"hour": "ساعت",
|
||||||
|
"details": "جزئیات مسیر",
|
||||||
|
"share": "اشتراک\u200Cگذاری",
|
||||||
|
"repeat": "بازنشانی",
|
||||||
|
"drive": "رانندگی",
|
||||||
|
"kilometer": "کیلومتر"
|
||||||
|
},
|
||||||
|
"SearchBox": {
|
||||||
|
"search": "جستجو در نقشه 141"
|
||||||
|
},
|
||||||
|
"fakeFavoriteLocation": {
|
||||||
|
"home": "خانه",
|
||||||
|
"work": "محل کار",
|
||||||
|
"others": "سایر ذخیره ها"
|
||||||
|
},
|
||||||
|
"Corridors": {
|
||||||
|
"title": "لیست کریدورها",
|
||||||
|
"fav-corridors": "کریدورهای موردعلاقه",
|
||||||
|
"fav-cameras": "دوربین های موردعلاقه",
|
||||||
|
"corridor_specification": "مشخصات کریدور",
|
||||||
|
"corridor_sights": "جاذبه های گردشگری"
|
||||||
|
},
|
||||||
|
"LocationDrawer": {
|
||||||
|
"select_destination": "انتخاب مقصد",
|
||||||
|
"confirm_destination": "تایید مقصد",
|
||||||
|
"confirm_origin": "تایید مبدا",
|
||||||
|
"confirm_waypoint": "تایید مقصد بعدی",
|
||||||
|
"back": "بازگشت",
|
||||||
|
"loading": "درحال دریافت داده...",
|
||||||
|
"route": "مسیرها",
|
||||||
|
"share": "ارسال",
|
||||||
|
"save": "ذخیره",
|
||||||
|
"more_detail": "نمایش\u200Cبیشتر",
|
||||||
|
"no_result": "نتیجه\u200Cای یافت نشد...",
|
||||||
|
"select_beginning": "انتخاب مبدا",
|
||||||
|
"road_cameras": "تصاویر دوربین های جاده ای",
|
||||||
|
"target_location": "انتخاب موقعیت مکانی شما",
|
||||||
|
"where_to": "جستجو در 141",
|
||||||
|
"choose_source": "مبدا را انتخاب کنید",
|
||||||
|
"choose_from_map": "انتخاب از روی نقشه"
|
||||||
|
},
|
||||||
|
"Sidebar": {
|
||||||
|
"showSideMenu": "نمایش منو کناری",
|
||||||
|
"login": "ورود",
|
||||||
|
"application": "اپلیکیشن",
|
||||||
|
"fav_corridors": "کریدورهای مورد علاقه",
|
||||||
|
"fav_cameras": "دوربین های مورد علاقه",
|
||||||
|
"register": "ثبت نام",
|
||||||
|
"corridors": "کریدورها",
|
||||||
|
"complaints": "ثبت شکایات",
|
||||||
|
"cameraImages": "تصاویر دوربین ها",
|
||||||
|
"road&traffic": "راه و ترافیک",
|
||||||
|
"mainCorridors": "کریدورهای اصلی",
|
||||||
|
"audioNews": "اخبار صوتی",
|
||||||
|
"news": "اخبار",
|
||||||
|
"audio_news": "اخبار صوتی",
|
||||||
|
"policy": "تمامی حقوق این وبسایت متعلق به مرکز مدیریت راه های کشور می باشد",
|
||||||
|
"supportText": "پشتیبانی آنلاین ۱۴۱",
|
||||||
|
"lightMode": "حالت روز",
|
||||||
|
"darkMode": "حالت شب",
|
||||||
|
"support": "پشتیبانی",
|
||||||
|
"history": "تاریخچه",
|
||||||
|
"saved": "ذخیره شده"
|
||||||
|
},
|
||||||
|
"Auth": {
|
||||||
|
"description": "با افتتاح حساب کاربری در سامانه ۱۴۱ می\u200Cتوانید از داده\u200Cهای تخصصی حمل و نقل، ثبت و پیگیری شکایات در کلیه حوزه\u200Cهای حمل و نقل جاده\u200Cای و بسیاری از خدمات دیگر بهره\u200Cمند شوید.",
|
||||||
|
"userName": "نام کاربری",
|
||||||
|
"login": "ورود",
|
||||||
|
"profile": "حساب کاربری",
|
||||||
|
"account": "حساب کاربری",
|
||||||
|
"loginWithUserName": "ورود با نام کاربری",
|
||||||
|
"loginWithPhoneNumber": "ورود با شماره همراه",
|
||||||
|
"signup": "ثبت نام",
|
||||||
|
"signInWithGovPortal": "ورود از طریق دولت من",
|
||||||
|
"password": "رمز عبور",
|
||||||
|
"passwordRequired": "رمز عبور خود را وارد کنید",
|
||||||
|
"atLeast6Characters": "رمز عبور باید حداقل 6 کاراکتر باشد",
|
||||||
|
"phoneNumberRequired": "شماره تلفن همراه خود را وارد کنید",
|
||||||
|
"userNameRequired": "نام کاربری خود را وارد کنید",
|
||||||
|
"enterValidPhoneNumber": "لطفا شماره تلفن همراه معتبر وارد کنید",
|
||||||
|
"authWelcomeTitle": "به مرکز مدیریت راهای کشور خوش آمدید!",
|
||||||
|
"authInfoText": "با افتتاح حساب کاربری در سامانه 141 میتوانید از دادههای تخصصی حمل و نقل، ثبت و پیگیری شکایات در کلیه حوزههای حمل و نقل جادهای و بسیاری از خدمات دیگر بهرهمند شوید.",
|
||||||
|
"phoneNumber": "شماره تلفن همراه",
|
||||||
|
"createAccount": "ایجاد حساب کاربری",
|
||||||
|
"enteringAccount": "ورود به حساب کاربری",
|
||||||
|
"sendCode": "ارسال کد",
|
||||||
|
"accountRecovery": "بازیابی حساب",
|
||||||
|
"resendOTP": "ارسال مجدد",
|
||||||
|
"otpCode": "رمز یکبار مصرف",
|
||||||
|
"logout": "خروج",
|
||||||
|
"enterAcountDescription": "برای استفاده از تمامی امکانات وارد شوید."
|
||||||
|
},
|
||||||
|
"Complaints": {
|
||||||
|
"MoshkelateRahForm": "فرم مشکلات راه",
|
||||||
|
"description": "توضیحات",
|
||||||
|
"complicationType": "نوع عارضه",
|
||||||
|
"mapNote": "اگر مکان انتخابی در سطح محور نباشد شکایت شما ثبت نمی گردد لطفا مکان را دقیق در نقشه انتخاب کنید.",
|
||||||
|
"selectedLocation": "محل انتخاب شده",
|
||||||
|
"howToSelectLocation": "با جابجا کردن نقشه محل را انتخاب کنید",
|
||||||
|
"clickForSelect": "برای انتخاب موقعیت کلیک کنید",
|
||||||
|
"zoomForSelect": "برای انتخاب کردن موقعیت، زوم نقشه را بیشتر کنید!",
|
||||||
|
"closeMap": "بستن نقشه",
|
||||||
|
"editLocation": "ویرایش",
|
||||||
|
"submitLocation": "ثبت مختصات و بستن نقشه",
|
||||||
|
"BariDakheliSahebKalaForm": "فرم باری داخلی صاحب کالا",
|
||||||
|
"BariDakheliRanandehForm": "فرم باری داخلی راننده",
|
||||||
|
"BariDakheliBarbariForm": "فرم باری داخلی باربری",
|
||||||
|
"BariPayaneHayeMarziRanandeh": "فرم باری پایانههای مرزی راننده",
|
||||||
|
"MosaferiDakheliMosafer": "فرم مسافری داخلی مسافر",
|
||||||
|
"MosaferiDakheliRanandeh": "فرم مسافری داخلی راننده",
|
||||||
|
"MosaferiDakheliTaavoni": "فرم مسافری داخلی تعاونی",
|
||||||
|
"MosaferiTransitMosafer": "فرم مسافری ترانزیت مسافر",
|
||||||
|
"MosaferiPayaneHayeMarziRanandeh": "فرم مسافری پایانههای مرزی راننده",
|
||||||
|
"MosaferiPayaneHayeMarziMosafer": "فرم مسافری پایانههای مرزی مسافر",
|
||||||
|
"BariTransitRanandeh": "فرم باری ترانزیت راننده",
|
||||||
|
"BariTransitSahebKala": "فرم باری ترانزیت صاحب کالا",
|
||||||
|
"BariTransitBarbari": "فرم باری ترانزیت باربری",
|
||||||
|
"category": "دسته بندی",
|
||||||
|
"complaint_subtitle_id": "موضوع",
|
||||||
|
"province_id": "استان",
|
||||||
|
"first_fill_category": "ابتدا دسته بندی را انتخاب کنید",
|
||||||
|
"first_fill_province": "ابتدا استان را انتخاب کنید",
|
||||||
|
"bearing_id": "شرکت باربری",
|
||||||
|
"followUpComplaints": "پیگیری شکایات ثبت شده",
|
||||||
|
"backToComplaints": "بازگشت به شکایات",
|
||||||
|
"addComplaint": "ثبت درخواست شکایت جدید",
|
||||||
|
"showComplaintDetails": "نمایش جزییات نتیجه شکایت",
|
||||||
|
"trackingCode": "کد رهگیری",
|
||||||
|
"complaintStatus": "وضعیت",
|
||||||
|
"plaintiff": "درخواست دهنده",
|
||||||
|
"complaintSubject": "موضوع شکایت",
|
||||||
|
"createdAt": "تاریخ ثبت شکایت"
|
||||||
|
},
|
||||||
|
"Bookmarks": {
|
||||||
|
"favoriteLocations": "مکانهای مورد علاقه",
|
||||||
|
"home": "خانه",
|
||||||
|
"work": "محل کار",
|
||||||
|
"others": "سایر",
|
||||||
|
"addBookmark": "افزودن مکان مورد علاقه",
|
||||||
|
"saveBookmark": "ذخیره",
|
||||||
|
"confirmDeleteText": "آیا از حذف این مکان مطمئن هستید؟",
|
||||||
|
"delete": "حذف",
|
||||||
|
"abort": "انصراف",
|
||||||
|
"signin": "ورود",
|
||||||
|
"signInModalTitle": "ورود به حساب کاربری",
|
||||||
|
"signInModalDescription": "برای استفاده از این قابلیت باید وارد حساب کاربری خود شوید."
|
||||||
|
},
|
||||||
|
"ContextMenu": {
|
||||||
|
"routingFromHere": "مسیریابی از اینجا",
|
||||||
|
"routingToHere": "مسیریابی به اینجا",
|
||||||
|
"copyCoordinates": "کپی کردن مختصات",
|
||||||
|
"clearRoutes": "پاک کردن مسیرها"
|
||||||
|
},
|
||||||
|
"logout": {
|
||||||
|
"logout": "خروج",
|
||||||
|
"confirmLogout": "آیا از خروج خود اطمینان دارید؟",
|
||||||
|
"cancel": "انصراف"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { NextConfig } from "next";
|
import { NextConfig } from "next";
|
||||||
|
import createNextIntlPlugin from "next-intl/plugin";
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {};
|
||||||
/* config options here */
|
|
||||||
};
|
|
||||||
|
|
||||||
export default nextConfig;
|
const withNextIntl = createNextIntlPlugin();
|
||||||
|
export default withNextIntl(nextConfig);
|
||||||
|
|||||||
35
package.json
@@ -6,20 +6,51 @@
|
|||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "eslint"
|
"lint": "eslint",
|
||||||
|
"format": "npx prettier . --write"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@dnd-kit/core": "^6.3.1",
|
||||||
|
"@dnd-kit/modifiers": "^9.0.0",
|
||||||
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
|
"@googlemaps/polyline-codec": "^1.0.28",
|
||||||
|
"@radix-ui/react-popover": "^1.1.15",
|
||||||
|
"@radix-ui/react-select": "^2.2.6",
|
||||||
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
|
"@svgr/webpack": "^8.1.0",
|
||||||
|
"@tailwindcss/postcss": "^4.1.4",
|
||||||
|
"@vis.gl/react-maplibre": "^8.0.4",
|
||||||
|
"axios": "^1.10.0",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"framer-motion": "^12.9.2",
|
||||||
|
"maplibre-gl": "^5.4.0",
|
||||||
"next": "16.1.1",
|
"next": "16.1.1",
|
||||||
|
"next-intl": "^4.6.1",
|
||||||
|
"next-pwa": "^5.6.0",
|
||||||
|
"next-themes": "^0.4.6",
|
||||||
|
"nextjs-toploader": "^3.8.16",
|
||||||
|
"postcss": "^8.5.3",
|
||||||
"react": "19.2.3",
|
"react": "19.2.3",
|
||||||
"react-dom": "19.2.3"
|
"react-day-picker": "^9.13.0",
|
||||||
|
"react-dom": "19.2.3",
|
||||||
|
"react-hot-toast": "^2.6.0",
|
||||||
|
"tailwind-merge": "^3.2.0",
|
||||||
|
"tailwind-scrollbar": "^4.0.2",
|
||||||
|
"tailwindcss": "^4.1.4",
|
||||||
|
"zustand": "^5.0.9"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
|
"@types/js-cookie": "^3.0.6",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "16.1.1",
|
"eslint-config-next": "16.1.1",
|
||||||
|
"prettier": "3.5.3",
|
||||||
|
"prettier-plugin-tailwindcss": "^0.6.14",
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
"typescript": "^5"
|
"typescript": "^5"
|
||||||
}
|
}
|
||||||
|
|||||||
10328
pnpm-lock.yaml
generated
@@ -1 +0,0 @@
|
|||||||
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 391 B |
79
public/fonts.css
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
@font-face {
|
||||||
|
font-family: "Ravi";
|
||||||
|
src:
|
||||||
|
url("./fonts/ravi/woff2/RaviFaNum-ExtraBlack.woff2") format("woff2"),
|
||||||
|
url("./fonts/ravi/woff/RaviFaNum-ExtraBlack.woff") format("woff");
|
||||||
|
font-weight: 900;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: "Ravi";
|
||||||
|
src:
|
||||||
|
url("./fonts/ravi/woff2/RaviFaNum-Black.woff2") format("woff2"),
|
||||||
|
url("./fonts/ravi/woff/RaviFaNum-Black.woff") format("woff");
|
||||||
|
font-weight: 800;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: "Ravi";
|
||||||
|
src:
|
||||||
|
url("./fonts/ravi/woff2/RaviFaNum-Bold.woff2") format("woff2"),
|
||||||
|
url("./fonts/ravi/woff/RaviFaNum-Bold.woff") format("woff");
|
||||||
|
font-weight: 700;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: "Ravi";
|
||||||
|
src:
|
||||||
|
url("./fonts/ravi/woff2/RaviFaNum-SemiBold.woff2") format("woff2"),
|
||||||
|
url("./fonts/ravi/woff/RaviFaNum-SemiBold.woff") format("woff");
|
||||||
|
font-weight: 600;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: "Ravi";
|
||||||
|
src:
|
||||||
|
url("./fonts/ravi/woff2/RaviFaNum-Medium.woff2") format("woff2"),
|
||||||
|
url("./fonts/ravi/woff/RaviFaNum-Medium.woff") format("woff");
|
||||||
|
font-weight: 500;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: "Ravi";
|
||||||
|
src:
|
||||||
|
url("./fonts/ravi/woff2/RaviFaNum-Regular.woff2") format("woff2"),
|
||||||
|
url("./fonts/ravi/woff/RaviFaNum-Regular.woff") format("woff");
|
||||||
|
font-weight: 400;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: "Ravi";
|
||||||
|
src:
|
||||||
|
url("./fonts/ravi/woff2/RaviFaNum-Light.woff2") format("woff2"),
|
||||||
|
url("./fonts/ravi/woff/RaviFaNum-Light.woff") format("woff");
|
||||||
|
font-weight: 300;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: "Ravi";
|
||||||
|
src:
|
||||||
|
url("./fonts/ravi/woff2/RaviFaNum-Thin.woff2") format("woff2"),
|
||||||
|
url("./fonts/ravi/woff/RaviFaNum-Thin.woff") format("woff");
|
||||||
|
font-weight: 200;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
BIN
public/fonts/ravi/woff/RaviFaNum-Black.woff
Normal file
BIN
public/fonts/ravi/woff/RaviFaNum-Bold.woff
Normal file
BIN
public/fonts/ravi/woff/RaviFaNum-ExtraBlack.woff
Normal file
BIN
public/fonts/ravi/woff/RaviFaNum-Light.woff
Normal file
BIN
public/fonts/ravi/woff/RaviFaNum-Medium.woff
Normal file
BIN
public/fonts/ravi/woff/RaviFaNum-Regular.woff
Normal file
BIN
public/fonts/ravi/woff/RaviFaNum-SemiBold.woff
Normal file
BIN
public/fonts/ravi/woff/RaviFaNum-Thin.woff
Normal file
BIN
public/fonts/ravi/woff2/RaviFaNum-Black.woff2
Normal file
BIN
public/fonts/ravi/woff2/RaviFaNum-Bold.woff2
Normal file
BIN
public/fonts/ravi/woff2/RaviFaNum-ExtraBlack.woff2
Normal file
BIN
public/fonts/ravi/woff2/RaviFaNum-Light.woff2
Normal file
BIN
public/fonts/ravi/woff2/RaviFaNum-Medium.woff2
Normal file
BIN
public/fonts/ravi/woff2/RaviFaNum-Regular.woff2
Normal file
BIN
public/fonts/ravi/woff2/RaviFaNum-SemiBold.woff2
Normal file
BIN
public/fonts/ravi/woff2/RaviFaNum-Thin.woff2
Normal file
@@ -1 +0,0 @@
|
|||||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.0 KiB |
BIN
public/logo/144px.png
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
BIN
public/logo/152px.png
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
BIN
public/logo/192px.png
Normal file
|
After Width: | Height: | Size: 2.9 KiB |
BIN
public/logo/384px.png
Normal file
|
After Width: | Height: | Size: 3.8 KiB |
BIN
public/logo/522px.png
Normal file
|
After Width: | Height: | Size: 3.8 KiB |
38
public/manifest.json
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
{
|
||||||
|
"id": "/",
|
||||||
|
"name": "سامانه 141",
|
||||||
|
"short_name": "141",
|
||||||
|
"description": "مرکز مدیریت راه های کشور",
|
||||||
|
"start_url": "/",
|
||||||
|
"display": "standalone",
|
||||||
|
"orientation": "portrait",
|
||||||
|
"theme_color": "#283618",
|
||||||
|
"background_color": "#ffffff",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "logo/144px.png",
|
||||||
|
"sizes": "144x144",
|
||||||
|
"type": "image/png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "logo/152px.png",
|
||||||
|
"sizes": "152x152",
|
||||||
|
"type": "image/png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "logo/192px.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "logo/384px.png",
|
||||||
|
"sizes": "384x384",
|
||||||
|
"type": "image/png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "logo/512px.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
9388
public/mapbox-gl-rtl-text.js
Normal file
@@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.3 KiB |
10
public/offline.html
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<title>Offline</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>You are offline</h1>
|
||||||
|
<p>Please check your connection.</p>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -1 +0,0 @@
|
|||||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 128 B |
@@ -1 +0,0 @@
|
|||||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 385 B |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 2.3 KiB |
@@ -1,26 +0,0 @@
|
|||||||
@import "tailwindcss";
|
|
||||||
|
|
||||||
:root {
|
|
||||||
--background: #ffffff;
|
|
||||||
--foreground: #171717;
|
|
||||||
}
|
|
||||||
|
|
||||||
@theme inline {
|
|
||||||
--color-background: var(--background);
|
|
||||||
--color-foreground: var(--foreground);
|
|
||||||
--font-sans: var(--font-geist-sans);
|
|
||||||
--font-mono: var(--font-geist-mono);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
:root {
|
|
||||||
--background: #0a0a0a;
|
|
||||||
--foreground: #ededed;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
background: var(--background);
|
|
||||||
color: var(--foreground);
|
|
||||||
font-family: Arial, Helvetica, sans-serif;
|
|
||||||
}
|
|
||||||
@@ -1,33 +1,46 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { Geist, Geist_Mono } from "next/font/google";
|
import "@/styles/globals.css";
|
||||||
import "./globals.css";
|
import { NextIntlClientProvider } from "next-intl";
|
||||||
|
import { getLocale, getMessages } from "next-intl/server";
|
||||||
const geistSans = Geist({
|
import NextTopLoader from "nextjs-toploader";
|
||||||
variable: "--font-geist-sans",
|
|
||||||
subsets: ["latin"],
|
|
||||||
});
|
|
||||||
|
|
||||||
const geistMono = Geist_Mono({
|
|
||||||
variable: "--font-geist-mono",
|
|
||||||
subsets: ["latin"],
|
|
||||||
});
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Create Next App",
|
title: {
|
||||||
description: "Generated by create next app",
|
template: "%s | سالن رو",
|
||||||
|
default: "سالن رو",
|
||||||
|
},
|
||||||
|
description: "وب سایت سالن رو",
|
||||||
|
authors: {
|
||||||
|
name: "شرکت میرفران تک",
|
||||||
|
url: "https://menulita.ir",
|
||||||
|
},
|
||||||
|
|
||||||
|
manifest: "/manifest.json",
|
||||||
|
icons: {
|
||||||
|
icon: "/logo/192px.png",
|
||||||
|
apple: "/logo/512px.png",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
export const viewport = {
|
||||||
|
width: "device-width",
|
||||||
|
initialScale: 1,
|
||||||
|
maximumScale: 1,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({
|
export default async function RootLayout({
|
||||||
children,
|
children,
|
||||||
}: Readonly<{
|
}: Readonly<{
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}>) {
|
}>) {
|
||||||
|
const locale = await getLocale();
|
||||||
|
const messages = await getMessages({ locale });
|
||||||
return (
|
return (
|
||||||
<html lang="en">
|
<html lang={locale} dir="rtl">
|
||||||
<body
|
<body>
|
||||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
<NextTopLoader color="#16a795" />
|
||||||
>
|
<NextIntlClientProvider locale={locale} messages={messages}>
|
||||||
{children}
|
{children}
|
||||||
|
</NextIntlClientProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,65 +1,6 @@
|
|||||||
import Image from "next/image";
|
import { useTranslations } from "next-intl";
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
return (
|
const t = useTranslations("HomePage");
|
||||||
<div className="flex min-h-screen items-center justify-center bg-zinc-50 font-sans dark:bg-black">
|
return <h1>{t("title")}</h1>;
|
||||||
<main className="flex min-h-screen w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
|
|
||||||
<Image
|
|
||||||
className="dark:invert"
|
|
||||||
src="/next.svg"
|
|
||||||
alt="Next.js logo"
|
|
||||||
width={100}
|
|
||||||
height={20}
|
|
||||||
priority
|
|
||||||
/>
|
|
||||||
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
|
|
||||||
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
|
|
||||||
To get started, edit the page.tsx file.
|
|
||||||
</h1>
|
|
||||||
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
|
|
||||||
Looking for a starting point or more instructions? Head over to{" "}
|
|
||||||
<a
|
|
||||||
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
|
||||||
>
|
|
||||||
Templates
|
|
||||||
</a>{" "}
|
|
||||||
or the{" "}
|
|
||||||
<a
|
|
||||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
|
||||||
>
|
|
||||||
Learning
|
|
||||||
</a>{" "}
|
|
||||||
center.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
|
|
||||||
<a
|
|
||||||
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
|
|
||||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
<Image
|
|
||||||
className="dark:invert"
|
|
||||||
src="/vercel.svg"
|
|
||||||
alt="Vercel logomark"
|
|
||||||
width={16}
|
|
||||||
height={16}
|
|
||||||
/>
|
|
||||||
Deploy Now
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
|
|
||||||
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
Documentation
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
2
src/assets/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { default as CheckIcon } from "&/icons/check-icon.svg";
|
||||||
|
export { default as ChevronIcon } from "&/icons/chevron-icon.svg";
|
||||||
26
src/assets/translations/filepond-fa.json
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"labelInvalidField": "این فیلد شامل فایلهای نامعتبر است",
|
||||||
|
"labelFileWaitingForSize": "در انتظار بررسی سایز فایل",
|
||||||
|
"labelFileSizeNotAvailable": "اندازه فایل {fileName} موجود نیست",
|
||||||
|
"labelFileLoading": "در حال بارگذاری",
|
||||||
|
"labelFileLoadError": "خطا هنگام بارگذاری",
|
||||||
|
"labelFileProcessing": "در حال آپلود",
|
||||||
|
"labelFileProcessingComplete": "آپلود کامل شد",
|
||||||
|
"labelFileProcessingAborted": "آپلود لغو شد",
|
||||||
|
"labelFileProcessingError": "خطا هنگام آپلود",
|
||||||
|
"labelTapToCancel": "برای لغو کلیک کنید",
|
||||||
|
"labelTapToRetry": "برای تلاش دوباره کلیک کنید",
|
||||||
|
"labelTapToUndo": "برای بازگشت کلیک کنید",
|
||||||
|
"labelButtonRemoveItem": "حذف",
|
||||||
|
"labelButtonAbortItemLoad": "لغو",
|
||||||
|
"labelButtonRetryItemLoad": "تلاش دوباره",
|
||||||
|
"labelButtonAbortItemProcessing": "لغو",
|
||||||
|
"labelButtonRetryItemProcessing": "تلاش دوباره",
|
||||||
|
"labelButtonProcessItem": "آپلود",
|
||||||
|
"labelMaxFileSizeExceeded": "حجم فایل بیش از حد مجاز است",
|
||||||
|
"labelMaxFileSize": "حداکثر حجم مجاز: {filesize}",
|
||||||
|
"labelFileTypeNotAllowed": "نوع فایل {fileName} مجاز نیست",
|
||||||
|
"labelFileSizeMegabytes": "مگابایت",
|
||||||
|
"labelFileSizeKilobytes": "کیلوبایت",
|
||||||
|
"labelFileSizeGigabytes": "گیگابایت"
|
||||||
|
}
|
||||||
82
src/components/UI/BottomSheet.tsx
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useBottomSheetStore } from "@/stores/useBottomSheetStore";
|
||||||
|
import { AnimatePresence, motion } from "framer-motion";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
|
|
||||||
|
const BottomSheet = () => {
|
||||||
|
const { isOpen, content, options, closeBottomSheet } = useBottomSheetStore();
|
||||||
|
|
||||||
|
const interactive = options?.interactiveBackground ?? false;
|
||||||
|
|
||||||
|
const viewportHeight = typeof window !== "undefined" ? window.innerHeight : 0;
|
||||||
|
|
||||||
|
// Escape key (only active for modal mode)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen || interactive) return;
|
||||||
|
|
||||||
|
const handleEscape = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape") closeBottomSheet();
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener("keydown", handleEscape);
|
||||||
|
document.body.style.overflow = "hidden";
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("keydown", handleEscape);
|
||||||
|
document.body.style.overflow = "unset";
|
||||||
|
};
|
||||||
|
}, [isOpen, interactive, closeBottomSheet]);
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<AnimatePresence>
|
||||||
|
{isOpen && (
|
||||||
|
<>
|
||||||
|
{/* Non-interactive background mode (modal) */}
|
||||||
|
{!interactive && (
|
||||||
|
<motion.div
|
||||||
|
key="modal-overlay"
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
transition={{ duration: 0.25 }}
|
||||||
|
className="fixed inset-0 z-50 bg-black/50"
|
||||||
|
onClick={closeBottomSheet}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* The sheet (works in both modes) */}
|
||||||
|
<motion.div
|
||||||
|
key="sheet"
|
||||||
|
initial={{ y: viewportHeight }}
|
||||||
|
animate={{ y: 0 }}
|
||||||
|
exit={{ y: viewportHeight }}
|
||||||
|
transition={{ type: "spring", damping: 30, stiffness: 300 }}
|
||||||
|
drag="y"
|
||||||
|
dragConstraints={{ top: 0, bottom: 0 }}
|
||||||
|
dragElastic={{ top: 0, bottom: 0.25 }}
|
||||||
|
onDragEnd={(e, info) => {
|
||||||
|
if (info.offset.y > 100 || info.velocity.y > 500) {
|
||||||
|
closeBottomSheet();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="bg-pwa-primary fixed inset-x-0 bottom-0 z-[60] mx-auto w-full max-w-md rounded-t-xl shadow-lg"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="flex justify-center py-2">
|
||||||
|
<div className="h-1 w-12 rounded-full bg-gray-400" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="overflow-y-auto p-4" style={{ maxHeight: "90vh" }} dir="rtl">
|
||||||
|
{content}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>,
|
||||||
|
document.body
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default BottomSheet;
|
||||||
161
src/components/UI/Calendar.tsx
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import * as React from "react";
|
||||||
|
import { Chevron, DayButton, getDefaultClassNames } from "react-day-picker";
|
||||||
|
import { DayPicker } from "react-day-picker/persian";
|
||||||
|
|
||||||
|
interface CalendarHijriProps {
|
||||||
|
selected?: Date;
|
||||||
|
onSelect?: (date: Date | undefined) => void;
|
||||||
|
defaultMonth?: Date;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CalendarHijri({ selected, onSelect, defaultMonth, className }: CalendarHijriProps) {
|
||||||
|
return (
|
||||||
|
<Calendar
|
||||||
|
mode="single"
|
||||||
|
defaultMonth={defaultMonth ?? selected ?? new Date()}
|
||||||
|
selected={selected}
|
||||||
|
onSelect={onSelect}
|
||||||
|
className={cn("rounded-lg shadow-sm", className)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Calendar({
|
||||||
|
className,
|
||||||
|
classNames,
|
||||||
|
showOutsideDays = true,
|
||||||
|
captionLayout = "label",
|
||||||
|
formatters,
|
||||||
|
components,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DayPicker>) {
|
||||||
|
const defaultClassNames = getDefaultClassNames();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DayPicker
|
||||||
|
showOutsideDays={showOutsideDays}
|
||||||
|
className={cn("bg-text text-desktop-primary p-3 [--cell-size:--spacing(10)]", className)}
|
||||||
|
captionLayout={captionLayout}
|
||||||
|
formatters={{
|
||||||
|
formatMonthDropdown: (date) => date.toLocaleString("default", { month: "short" }),
|
||||||
|
...formatters,
|
||||||
|
}}
|
||||||
|
classNames={{
|
||||||
|
root: cn("w-fit", defaultClassNames.root),
|
||||||
|
months: cn("flex gap-4 flex-col md:flex-row relative", defaultClassNames.months),
|
||||||
|
month: cn("flex flex-col w-full gap-3", defaultClassNames.month),
|
||||||
|
nav: cn("flex items-center w-full absolute top-0 inset-x-0 justify-between", defaultClassNames.nav),
|
||||||
|
button_previous: cn(
|
||||||
|
"size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
|
||||||
|
defaultClassNames.button_previous
|
||||||
|
),
|
||||||
|
button_next: cn(
|
||||||
|
"size-(--cell-size) aria-disabled:opacity-50 select-none",
|
||||||
|
defaultClassNames.button_next
|
||||||
|
),
|
||||||
|
month_caption: cn(
|
||||||
|
"flex items-center justify-center h-(--cell-size) w-full px-(--cell-size)",
|
||||||
|
defaultClassNames.month_caption
|
||||||
|
),
|
||||||
|
dropdowns: cn(
|
||||||
|
"w-full flex items-center text-sm font-medium justify-center h-(--cell-size) gap-1.5",
|
||||||
|
defaultClassNames.dropdowns
|
||||||
|
),
|
||||||
|
dropdown_root: cn(
|
||||||
|
"relative has-focus:border-ring border border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] rounded-md",
|
||||||
|
defaultClassNames.dropdown_root
|
||||||
|
),
|
||||||
|
dropdown: cn("absolute inset-0 opacity-0", defaultClassNames.dropdown),
|
||||||
|
caption_label: cn(
|
||||||
|
"select-none font-medium",
|
||||||
|
captionLayout === "label"
|
||||||
|
? "text-sm"
|
||||||
|
: "rounded-md pl-2 pr-1 flex items-center gap-1 text-sm h-8 [&>svg]:text-text-desktop-primary [&>svg]:size-3.5",
|
||||||
|
defaultClassNames.caption_label
|
||||||
|
),
|
||||||
|
table: "w-full border-collapse",
|
||||||
|
weekdays: cn("flex items-center justify-center", defaultClassNames.weekdays),
|
||||||
|
weekday: cn(
|
||||||
|
"text-neo-aqua rounded-md flex-1 font-normal text-sm select-none",
|
||||||
|
defaultClassNames.weekday
|
||||||
|
),
|
||||||
|
week: cn("flex w-full mt-2", defaultClassNames.week),
|
||||||
|
week_number_header: cn("select-none w-(--cell-size)", defaultClassNames.week_number_header),
|
||||||
|
week_number: cn("text-sm select-none text-desktop-primary", defaultClassNames.week_number),
|
||||||
|
day: cn(
|
||||||
|
"relative w-full h-full text-center [&:first-child[data-selected=true]_button]:rounded-l-md [&:last-child[data-selected=true]_button]:rounded-r-md group/day aspect-square select-none",
|
||||||
|
defaultClassNames.day
|
||||||
|
),
|
||||||
|
range_start: cn("rounded-l-md bg-accent", defaultClassNames.range_start),
|
||||||
|
range_middle: cn("rounded-none", defaultClassNames.range_middle),
|
||||||
|
range_end: cn("rounded-r-md bg-accent", defaultClassNames.range_end),
|
||||||
|
today: cn(
|
||||||
|
"bg-neo-aqua/10 text-accent-foreground rounded-md data-[selected=true]:rounded-none",
|
||||||
|
defaultClassNames.today
|
||||||
|
),
|
||||||
|
outside: cn(
|
||||||
|
"text-text-desktop-primary aria-selected:text-text-desktop-primary",
|
||||||
|
defaultClassNames.outside
|
||||||
|
),
|
||||||
|
disabled: cn("text-text-desktop-primary opacity-50", defaultClassNames.disabled),
|
||||||
|
hidden: cn("invisible", defaultClassNames.hidden),
|
||||||
|
...classNames,
|
||||||
|
}}
|
||||||
|
components={{
|
||||||
|
Root: ({ className, rootRef, ...props }) => (
|
||||||
|
<div data-slot="calendar" ref={rootRef} className={cn(className)} {...props} />
|
||||||
|
),
|
||||||
|
Chevron: ({ className, orientation, ...props }) => {
|
||||||
|
if (orientation === "left") {
|
||||||
|
return <Chevron className={cn("size-4 rotate-180", className)} {...props} />;
|
||||||
|
}
|
||||||
|
if (orientation === "right") {
|
||||||
|
return <Chevron className={cn("size-4 justify-self-end", className)} {...props} />;
|
||||||
|
}
|
||||||
|
return <Chevron className={cn("size-4 rotate-90", className)} {...props} />;
|
||||||
|
},
|
||||||
|
DayButton: CalendarDayButton,
|
||||||
|
WeekNumber: ({ children, ...props }) => (
|
||||||
|
<td {...props}>
|
||||||
|
<div className="flex size-(--cell-size) items-center justify-center text-center">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
),
|
||||||
|
...components,
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CalendarDayButton({ className, day, modifiers, ...props }: React.ComponentProps<typeof DayButton>) {
|
||||||
|
const defaultClassNames = getDefaultClassNames();
|
||||||
|
const ref = React.useRef<HTMLButtonElement>(null);
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (modifiers.focused) ref.current?.focus();
|
||||||
|
}, [modifiers.focused]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
ref={ref}
|
||||||
|
data-day={day.date.toLocaleDateString()}
|
||||||
|
data-selected-single={
|
||||||
|
modifiers.selected && !modifiers.range_start && !modifiers.range_end && !modifiers.range_middle
|
||||||
|
}
|
||||||
|
data-range-start={modifiers.range_start}
|
||||||
|
data-range-end={modifiers.range_end}
|
||||||
|
data-range-middle={modifiers.range_middle}
|
||||||
|
className={cn(
|
||||||
|
"data-[selected-single=true]:bg-neo-aqua data-[selected-single=true]:text-text data-[range-middle=true]:bg-neo-aqua/20 data-[range-middle=true]:text-text data-[range-start=true]:bg-neo-aqua/20 data-[range-start=true]:text-text data-[range-end=true]:bg-neo-aqua/20 data-[range-end=true]:text-text flex aspect-square size-auto w-full min-w-(--cell-size) flex-col items-center justify-center gap-1 rounded leading-none group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:scale-90 data-[range-end=true]:rounded-md data-[range-end=true]:rounded-r-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md data-[range-start=true]:rounded-l-md [&>span]:text-xs [&>span]:opacity-70",
|
||||||
|
defaultClassNames.day,
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
66
src/components/UI/Modal.tsx
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useModalStore } from "@/stores/useModalStore";
|
||||||
|
import { AnimatePresence, motion } from "framer-motion";
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
|
|
||||||
|
const Modal = () => {
|
||||||
|
const isOpen = useModalStore((s) => s.isOpen);
|
||||||
|
const content = useModalStore((s) => s.content);
|
||||||
|
const closeModal = useModalStore((s) => s.closeModal);
|
||||||
|
|
||||||
|
const modalRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Handle escape key to close
|
||||||
|
useEffect(() => {
|
||||||
|
const handleEscape = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape") closeModal();
|
||||||
|
};
|
||||||
|
if (isOpen) {
|
||||||
|
document.addEventListener("keydown", handleEscape);
|
||||||
|
document.body.style.overflow = "hidden"; // Prevent scrolling
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("keydown", handleEscape);
|
||||||
|
document.body.style.overflow = "unset";
|
||||||
|
};
|
||||||
|
}, [isOpen, closeModal]);
|
||||||
|
|
||||||
|
const handleClickOutside = (e: React.MouseEvent) => {
|
||||||
|
if (modalRef.current && !modalRef.current.contains(e.target as Node)) {
|
||||||
|
closeModal();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<AnimatePresence>
|
||||||
|
<motion.div
|
||||||
|
role="dialog"
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
transition={{ duration: 0.3 }}
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
|
||||||
|
onClick={handleClickOutside}
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
ref={modalRef}
|
||||||
|
initial={{ scale: 0.95, opacity: 0 }}
|
||||||
|
animate={{ scale: 1, opacity: 1 }}
|
||||||
|
exit={{ scale: 0.95, opacity: 0 }}
|
||||||
|
transition={{ duration: 0.3 }}
|
||||||
|
className="relative rounded-lg shadow-lg"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
</AnimatePresence>,
|
||||||
|
document.body
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Modal;
|
||||||
42
src/components/UI/Popover.tsx
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import * as PopoverPrimitive from "@radix-ui/react-popover";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
function Popover({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Root>) {
|
||||||
|
return <PopoverPrimitive.Root data-slot="popover" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function PopoverTrigger({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
|
||||||
|
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function PopoverContent({
|
||||||
|
className,
|
||||||
|
align = "center",
|
||||||
|
sideOffset = 4,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<PopoverPrimitive.Portal>
|
||||||
|
<PopoverPrimitive.Content
|
||||||
|
data-slot="popover-content"
|
||||||
|
align={align}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</PopoverPrimitive.Portal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PopoverAnchor({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
|
||||||
|
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor };
|
||||||
147
src/components/UI/Select.tsx
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
"use client";
|
||||||
|
import * as React from "react";
|
||||||
|
import * as SelectPrimitive from "@radix-ui/react-select";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { CheckIcon, ChevronIcon } from "@/assets";
|
||||||
|
function Select({ ...props }: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
||||||
|
return <SelectPrimitive.Root data-slot="select" {...props} />;
|
||||||
|
}
|
||||||
|
function SelectGroup({ ...props }: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
||||||
|
return <SelectPrimitive.Group data-slot="select-group" {...props} />;
|
||||||
|
}
|
||||||
|
function SelectValue({ ...props }: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
||||||
|
return <SelectPrimitive.Value data-slot="select-value" {...props} />;
|
||||||
|
}
|
||||||
|
function SelectTrigger({
|
||||||
|
className,
|
||||||
|
size = "default",
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
|
||||||
|
size?: "sm" | "default";
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Trigger
|
||||||
|
data-slot="select-trigger"
|
||||||
|
data-size={size}
|
||||||
|
className={cn(
|
||||||
|
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<SelectPrimitive.Icon asChild>
|
||||||
|
<ChevronIcon className="text-text size-4 rotate-270" />
|
||||||
|
</SelectPrimitive.Icon>
|
||||||
|
</SelectPrimitive.Trigger>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
function SelectContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
position = "popper",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Portal>
|
||||||
|
<SelectPrimitive.Content
|
||||||
|
data-slot="select-content"
|
||||||
|
className={cn(
|
||||||
|
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
|
||||||
|
position === "popper" &&
|
||||||
|
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
position={position}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SelectScrollUpButton />
|
||||||
|
<SelectPrimitive.Viewport
|
||||||
|
className={cn(
|
||||||
|
"p-1",
|
||||||
|
position === "popper" &&
|
||||||
|
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</SelectPrimitive.Viewport>
|
||||||
|
<SelectScrollDownButton />
|
||||||
|
</SelectPrimitive.Content>
|
||||||
|
</SelectPrimitive.Portal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
function SelectLabel({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.Label>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Label
|
||||||
|
data-slot="select-label"
|
||||||
|
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
function SelectItem({ className, children, ...props }: React.ComponentProps<typeof SelectPrimitive.Item>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Item
|
||||||
|
data-slot="select-item"
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="absolute left-2 flex size-3.5 items-center justify-center">
|
||||||
|
<SelectPrimitive.ItemIndicator>
|
||||||
|
<CheckIcon size={4} />
|
||||||
|
</SelectPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||||
|
</SelectPrimitive.Item>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
function SelectSeparator({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.Separator>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Separator
|
||||||
|
data-slot="select-separator"
|
||||||
|
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
function SelectScrollUpButton({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.ScrollUpButton
|
||||||
|
data-slot="select-scroll-up-button"
|
||||||
|
className={cn("flex cursor-default items-center justify-center py-1", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronIcon className="text-desktop-primary size-4 rotate-90" />
|
||||||
|
</SelectPrimitive.ScrollUpButton>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
function SelectScrollDownButton({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.ScrollDownButton
|
||||||
|
data-slot="select-scroll-down-button"
|
||||||
|
className={cn("flex cursor-default items-center justify-center py-1", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronIcon className="text-desktop-primary size-4 rotate-270" />
|
||||||
|
</SelectPrimitive.ScrollDownButton>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
export {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectGroup,
|
||||||
|
SelectItem,
|
||||||
|
SelectLabel,
|
||||||
|
SelectScrollDownButton,
|
||||||
|
SelectScrollUpButton,
|
||||||
|
SelectSeparator,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
};
|
||||||
14
src/components/UI/Separator.tsx
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { HTMLAttributes } from "react";
|
||||||
|
|
||||||
|
type SeparatorProps = {
|
||||||
|
className?: HTMLAttributes<HTMLDivElement> | string | undefined;
|
||||||
|
vertical?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Separator({ className, vertical }: SeparatorProps) {
|
||||||
|
if (vertical) {
|
||||||
|
return <div className={cn("border-text/15 h-full border-r", className)} />;
|
||||||
|
}
|
||||||
|
return <div className={cn("border-text/15 w-full border-b", className)} />;
|
||||||
|
}
|
||||||
492
src/components/UI/Sortable.tsx
Normal file
@@ -0,0 +1,492 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
type Announcements,
|
||||||
|
closestCenter,
|
||||||
|
closestCorners,
|
||||||
|
DndContext,
|
||||||
|
type DndContextProps,
|
||||||
|
type DragEndEvent,
|
||||||
|
type DraggableAttributes,
|
||||||
|
type DraggableSyntheticListeners,
|
||||||
|
DragOverlay,
|
||||||
|
type DragStartEvent,
|
||||||
|
type DropAnimation,
|
||||||
|
defaultDropAnimationSideEffects,
|
||||||
|
KeyboardSensor,
|
||||||
|
MouseSensor,
|
||||||
|
type ScreenReaderInstructions,
|
||||||
|
TouchSensor,
|
||||||
|
type UniqueIdentifier,
|
||||||
|
useSensor,
|
||||||
|
useSensors,
|
||||||
|
} from "@dnd-kit/core";
|
||||||
|
import { restrictToHorizontalAxis, restrictToParentElement, restrictToVerticalAxis } from "@dnd-kit/modifiers";
|
||||||
|
import {
|
||||||
|
arrayMove,
|
||||||
|
horizontalListSortingStrategy,
|
||||||
|
SortableContext,
|
||||||
|
type SortableContextProps,
|
||||||
|
sortableKeyboardCoordinates,
|
||||||
|
useSortable,
|
||||||
|
verticalListSortingStrategy,
|
||||||
|
} from "@dnd-kit/sortable";
|
||||||
|
import { CSS } from "@dnd-kit/utilities";
|
||||||
|
import { Slot } from "@radix-ui/react-slot";
|
||||||
|
import * as React from "react";
|
||||||
|
import * as ReactDOM from "react-dom";
|
||||||
|
import { useComposedRefs } from "@/lib/compose-refs";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const orientationConfig = {
|
||||||
|
vertical: {
|
||||||
|
modifiers: [restrictToVerticalAxis, restrictToParentElement],
|
||||||
|
strategy: verticalListSortingStrategy,
|
||||||
|
collisionDetection: closestCenter,
|
||||||
|
},
|
||||||
|
horizontal: {
|
||||||
|
modifiers: [restrictToHorizontalAxis, restrictToParentElement],
|
||||||
|
strategy: horizontalListSortingStrategy,
|
||||||
|
collisionDetection: closestCenter,
|
||||||
|
},
|
||||||
|
mixed: {
|
||||||
|
modifiers: [restrictToParentElement],
|
||||||
|
strategy: undefined,
|
||||||
|
collisionDetection: closestCorners,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const ROOT_NAME = "Sortable";
|
||||||
|
const CONTENT_NAME = "SortableContent";
|
||||||
|
const ITEM_NAME = "SortableItem";
|
||||||
|
const ITEM_HANDLE_NAME = "SortableItemHandle";
|
||||||
|
const OVERLAY_NAME = "SortableOverlay";
|
||||||
|
|
||||||
|
interface SortableRootContextValue<T> {
|
||||||
|
id: string;
|
||||||
|
items: UniqueIdentifier[];
|
||||||
|
modifiers: DndContextProps["modifiers"];
|
||||||
|
strategy: SortableContextProps["strategy"];
|
||||||
|
activeId: UniqueIdentifier | null;
|
||||||
|
setActiveId: (id: UniqueIdentifier | null) => void;
|
||||||
|
getItemValue: (item: T) => UniqueIdentifier;
|
||||||
|
flatCursor: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SortableRootContext = React.createContext<SortableRootContextValue<unknown> | null>(null);
|
||||||
|
|
||||||
|
function useSortableContext(consumerName: string) {
|
||||||
|
const context = React.useContext(SortableRootContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error(`\`${consumerName}\` must be used within \`${ROOT_NAME}\``);
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GetItemValue<T> {
|
||||||
|
/**
|
||||||
|
* Callback that returns a unique identifier for each sortable item. Required for array of objects.
|
||||||
|
* @example getItemValue={(item) => item.id}
|
||||||
|
*/
|
||||||
|
getItemValue: (item: T) => UniqueIdentifier;
|
||||||
|
}
|
||||||
|
|
||||||
|
type SortableRootProps<T> = DndContextProps &
|
||||||
|
(T extends object ? GetItemValue<T> : Partial<GetItemValue<T>>) & {
|
||||||
|
value: T[];
|
||||||
|
onValueChange?: (items: T[]) => void;
|
||||||
|
onMove?: (event: DragEndEvent & { activeIndex: number; overIndex: number }) => void;
|
||||||
|
strategy?: SortableContextProps["strategy"];
|
||||||
|
orientation?: "vertical" | "horizontal" | "mixed";
|
||||||
|
flatCursor?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
function SortableRoot<T>(props: SortableRootProps<T>) {
|
||||||
|
const {
|
||||||
|
value,
|
||||||
|
onValueChange,
|
||||||
|
collisionDetection,
|
||||||
|
modifiers,
|
||||||
|
strategy,
|
||||||
|
onMove,
|
||||||
|
orientation = "vertical",
|
||||||
|
flatCursor = false,
|
||||||
|
getItemValue: getItemValueProp,
|
||||||
|
accessibility,
|
||||||
|
...sortableProps
|
||||||
|
} = props;
|
||||||
|
|
||||||
|
const id = React.useId();
|
||||||
|
const [activeId, setActiveId] = React.useState<UniqueIdentifier | null>(null);
|
||||||
|
|
||||||
|
const sensors = useSensors(
|
||||||
|
useSensor(MouseSensor),
|
||||||
|
useSensor(TouchSensor),
|
||||||
|
useSensor(KeyboardSensor, {
|
||||||
|
coordinateGetter: sortableKeyboardCoordinates,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const config = React.useMemo(() => orientationConfig[orientation], [orientation]);
|
||||||
|
|
||||||
|
const getItemValue = React.useCallback(
|
||||||
|
(item: T): UniqueIdentifier => {
|
||||||
|
if (typeof item === "object" && !getItemValueProp) {
|
||||||
|
throw new Error("getItemValue is required when using array of objects");
|
||||||
|
}
|
||||||
|
return getItemValueProp ? getItemValueProp(item) : (item as UniqueIdentifier);
|
||||||
|
},
|
||||||
|
[getItemValueProp]
|
||||||
|
);
|
||||||
|
|
||||||
|
const items = React.useMemo(() => {
|
||||||
|
return value.map((item) => getItemValue(item));
|
||||||
|
}, [value, getItemValue]);
|
||||||
|
|
||||||
|
const onDragStart = (event: DragStartEvent) => {
|
||||||
|
sortableProps.onDragStart?.(event);
|
||||||
|
|
||||||
|
if (event.activatorEvent.defaultPrevented) return;
|
||||||
|
|
||||||
|
setActiveId(event.active.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDragEnd = (event: DragEndEvent) => {
|
||||||
|
sortableProps.onDragEnd?.(event);
|
||||||
|
|
||||||
|
if (event.activatorEvent.defaultPrevented) return;
|
||||||
|
|
||||||
|
const { active, over } = event;
|
||||||
|
if (over && active.id !== over?.id) {
|
||||||
|
const activeIndex = value.findIndex((item) => getItemValue(item) === active.id);
|
||||||
|
const overIndex = value.findIndex((item) => getItemValue(item) === over.id);
|
||||||
|
|
||||||
|
if (onMove) {
|
||||||
|
onMove({ ...event, activeIndex, overIndex });
|
||||||
|
} else {
|
||||||
|
onValueChange?.(arrayMove(value, activeIndex, overIndex));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setActiveId(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDragCancel = (event: DragEndEvent) => {
|
||||||
|
sortableProps.onDragCancel?.(event);
|
||||||
|
|
||||||
|
if (event.activatorEvent.defaultPrevented) return;
|
||||||
|
|
||||||
|
setActiveId(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const announcements: Announcements = React.useMemo(
|
||||||
|
() => ({
|
||||||
|
onDragStart({ active }) {
|
||||||
|
const activeValue = active.id.toString();
|
||||||
|
return `Grabbed sortable item "${activeValue}". Current position is ${active.data.current?.sortable.index + 1} of ${value.length}. Use arrow keys to move, space to drop.`;
|
||||||
|
},
|
||||||
|
onDragOver({ active, over }) {
|
||||||
|
if (over) {
|
||||||
|
const overIndex = over.data.current?.sortable.index ?? 0;
|
||||||
|
const activeIndex = active.data.current?.sortable.index ?? 0;
|
||||||
|
const moveDirection = overIndex > activeIndex ? "down" : "up";
|
||||||
|
const activeValue = active.id.toString();
|
||||||
|
return `Sortable item "${activeValue}" moved ${moveDirection} to position ${overIndex + 1} of ${value.length}.`;
|
||||||
|
}
|
||||||
|
return "Sortable item is no longer over a droppable area. Press escape to cancel.";
|
||||||
|
},
|
||||||
|
onDragEnd({ active, over }) {
|
||||||
|
const activeValue = active.id.toString();
|
||||||
|
if (over) {
|
||||||
|
const overIndex = over.data.current?.sortable.index ?? 0;
|
||||||
|
return `Sortable item "${activeValue}" dropped at position ${overIndex + 1} of ${value.length}.`;
|
||||||
|
}
|
||||||
|
return `Sortable item "${activeValue}" dropped. No changes were made.`;
|
||||||
|
},
|
||||||
|
onDragCancel({ active }) {
|
||||||
|
const activeIndex = active.data.current?.sortable.index ?? 0;
|
||||||
|
const activeValue = active.id.toString();
|
||||||
|
return `Sorting cancelled. Sortable item "${activeValue}" returned to position ${activeIndex + 1} of ${value.length}.`;
|
||||||
|
},
|
||||||
|
onDragMove({ active, over }) {
|
||||||
|
if (over) {
|
||||||
|
const overIndex = over.data.current?.sortable.index ?? 0;
|
||||||
|
const activeIndex = active.data.current?.sortable.index ?? 0;
|
||||||
|
const moveDirection = overIndex > activeIndex ? "down" : "up";
|
||||||
|
const activeValue = active.id.toString();
|
||||||
|
return `Sortable item "${activeValue}" is moving ${moveDirection} to position ${overIndex + 1} of ${value.length}.`;
|
||||||
|
}
|
||||||
|
return "Sortable item is no longer over a droppable area. Press escape to cancel.";
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
[value]
|
||||||
|
);
|
||||||
|
|
||||||
|
const screenReaderInstructions: ScreenReaderInstructions = React.useMemo(
|
||||||
|
() => ({
|
||||||
|
draggable: `
|
||||||
|
To pick up a sortable item, press space or enter.
|
||||||
|
While dragging, use the ${orientation === "vertical" ? "up and down" : orientation === "horizontal" ? "left and right" : "arrow"} keys to move the item.
|
||||||
|
Press space or enter again to drop the item in its new position, or press escape to cancel.
|
||||||
|
`,
|
||||||
|
}),
|
||||||
|
[orientation]
|
||||||
|
);
|
||||||
|
|
||||||
|
const contextValue = React.useMemo(
|
||||||
|
() => ({
|
||||||
|
id,
|
||||||
|
items,
|
||||||
|
modifiers: modifiers ?? config.modifiers,
|
||||||
|
strategy: strategy ?? config.strategy,
|
||||||
|
activeId,
|
||||||
|
setActiveId,
|
||||||
|
getItemValue,
|
||||||
|
flatCursor,
|
||||||
|
}),
|
||||||
|
[id, items, modifiers, strategy, config.modifiers, config.strategy, activeId, getItemValue, flatCursor]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SortableRootContext.Provider value={contextValue as SortableRootContextValue<unknown>}>
|
||||||
|
<DndContext
|
||||||
|
collisionDetection={collisionDetection ?? config.collisionDetection}
|
||||||
|
modifiers={modifiers ?? config.modifiers}
|
||||||
|
sensors={sensors}
|
||||||
|
{...sortableProps}
|
||||||
|
id={id}
|
||||||
|
onDragStart={onDragStart}
|
||||||
|
onDragEnd={onDragEnd}
|
||||||
|
onDragCancel={onDragCancel}
|
||||||
|
accessibility={{
|
||||||
|
announcements,
|
||||||
|
screenReaderInstructions,
|
||||||
|
...accessibility,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</SortableRootContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const SortableContentContext = React.createContext<boolean>(false);
|
||||||
|
|
||||||
|
interface SortableContentProps extends React.ComponentProps<"div"> {
|
||||||
|
strategy?: SortableContextProps["strategy"];
|
||||||
|
children: React.ReactNode;
|
||||||
|
asChild?: boolean;
|
||||||
|
withoutSlot?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SortableContent(props: SortableContentProps) {
|
||||||
|
const { strategy: strategyProp, asChild, withoutSlot, children, ref, ...contentProps } = props;
|
||||||
|
|
||||||
|
const context = useSortableContext(CONTENT_NAME);
|
||||||
|
|
||||||
|
const ContentPrimitive = asChild ? Slot : "div";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SortableContentContext.Provider value={true}>
|
||||||
|
<SortableContext items={context.items} strategy={strategyProp ?? context.strategy}>
|
||||||
|
{withoutSlot ? (
|
||||||
|
children
|
||||||
|
) : (
|
||||||
|
<ContentPrimitive data-slot="sortable-content" {...contentProps} ref={ref}>
|
||||||
|
{children}
|
||||||
|
</ContentPrimitive>
|
||||||
|
)}
|
||||||
|
</SortableContext>
|
||||||
|
</SortableContentContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SortableItemContextValue {
|
||||||
|
id: string;
|
||||||
|
attributes: DraggableAttributes;
|
||||||
|
listeners: DraggableSyntheticListeners | undefined;
|
||||||
|
setActivatorNodeRef: (node: HTMLElement | null) => void;
|
||||||
|
isDragging?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SortableItemContext = React.createContext<SortableItemContextValue | null>(null);
|
||||||
|
|
||||||
|
function useSortableItemContext(consumerName: string) {
|
||||||
|
const context = React.useContext(SortableItemContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error(`\`${consumerName}\` must be used within \`${ITEM_NAME}\``);
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SortableItemProps extends React.ComponentProps<"div"> {
|
||||||
|
value: UniqueIdentifier;
|
||||||
|
asHandle?: boolean;
|
||||||
|
asChild?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SortableItem(props: SortableItemProps) {
|
||||||
|
const { value, style, asHandle, asChild, disabled, className, ref, ...itemProps } = props;
|
||||||
|
|
||||||
|
const inSortableContent = React.useContext(SortableContentContext);
|
||||||
|
const inSortableOverlay = React.useContext(SortableOverlayContext);
|
||||||
|
|
||||||
|
if (!inSortableContent && !inSortableOverlay) {
|
||||||
|
throw new Error(`\`${ITEM_NAME}\` must be used within \`${CONTENT_NAME}\` or \`${OVERLAY_NAME}\``);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value === "") {
|
||||||
|
throw new Error(`\`${ITEM_NAME}\` value cannot be an empty string`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const context = useSortableContext(ITEM_NAME);
|
||||||
|
const id = React.useId();
|
||||||
|
const { attributes, listeners, setNodeRef, setActivatorNodeRef, transform, transition, isDragging } = useSortable({
|
||||||
|
id: value,
|
||||||
|
disabled,
|
||||||
|
});
|
||||||
|
|
||||||
|
const composedRef = useComposedRefs(ref, (node) => {
|
||||||
|
if (disabled) return;
|
||||||
|
setNodeRef(node);
|
||||||
|
if (asHandle) setActivatorNodeRef(node);
|
||||||
|
});
|
||||||
|
|
||||||
|
const composedStyle = React.useMemo<React.CSSProperties>(() => {
|
||||||
|
return {
|
||||||
|
transform: CSS.Translate.toString(transform),
|
||||||
|
transition,
|
||||||
|
...style,
|
||||||
|
};
|
||||||
|
}, [transform, transition, style]);
|
||||||
|
|
||||||
|
const itemContext = React.useMemo<SortableItemContextValue>(
|
||||||
|
() => ({
|
||||||
|
id,
|
||||||
|
attributes,
|
||||||
|
listeners,
|
||||||
|
setActivatorNodeRef,
|
||||||
|
isDragging,
|
||||||
|
disabled,
|
||||||
|
}),
|
||||||
|
[id, attributes, listeners, setActivatorNodeRef, isDragging, disabled]
|
||||||
|
);
|
||||||
|
|
||||||
|
const ItemPrimitive = asChild ? Slot : "div";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SortableItemContext.Provider value={itemContext}>
|
||||||
|
<ItemPrimitive
|
||||||
|
id={id}
|
||||||
|
data-disabled={disabled}
|
||||||
|
data-dragging={isDragging ? "" : undefined}
|
||||||
|
data-slot="sortable-item"
|
||||||
|
{...itemProps}
|
||||||
|
{...(asHandle && !disabled ? attributes : {})}
|
||||||
|
{...(asHandle && !disabled ? listeners : {})}
|
||||||
|
ref={composedRef}
|
||||||
|
style={composedStyle}
|
||||||
|
className={cn(
|
||||||
|
"focus-visible:ring-ring focus-visible:ring-1 focus-visible:ring-offset-1 focus-visible:outline-hidden",
|
||||||
|
{
|
||||||
|
"touch-none select-none": asHandle,
|
||||||
|
"cursor-default": context.flatCursor,
|
||||||
|
"data-dragging:cursor-grabbing": !context.flatCursor,
|
||||||
|
"cursor-grab": !isDragging && asHandle && !context.flatCursor,
|
||||||
|
"opacity-50": isDragging,
|
||||||
|
"pointer-events-none opacity-50": disabled,
|
||||||
|
},
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</SortableItemContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SortableItemHandleProps extends React.ComponentProps<"button"> {
|
||||||
|
asChild?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SortableItemHandle(props: SortableItemHandleProps) {
|
||||||
|
const { asChild, disabled, className, ref, ...itemHandleProps } = props;
|
||||||
|
|
||||||
|
const context = useSortableContext(ITEM_HANDLE_NAME);
|
||||||
|
const itemContext = useSortableItemContext(ITEM_HANDLE_NAME);
|
||||||
|
|
||||||
|
const isDisabled = disabled ?? itemContext.disabled;
|
||||||
|
|
||||||
|
const composedRef = useComposedRefs(ref, (node) => {
|
||||||
|
if (!isDisabled) return;
|
||||||
|
itemContext.setActivatorNodeRef(node);
|
||||||
|
});
|
||||||
|
|
||||||
|
const HandlePrimitive = asChild ? Slot : "button";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<HandlePrimitive
|
||||||
|
type="button"
|
||||||
|
aria-controls={itemContext.id}
|
||||||
|
data-disabled={isDisabled}
|
||||||
|
data-dragging={itemContext.isDragging ? "" : undefined}
|
||||||
|
data-slot="sortable-item-handle"
|
||||||
|
{...itemHandleProps}
|
||||||
|
{...(isDisabled ? {} : itemContext.attributes)}
|
||||||
|
{...(isDisabled ? {} : itemContext.listeners)}
|
||||||
|
ref={composedRef}
|
||||||
|
className={cn(
|
||||||
|
"select-none disabled:pointer-events-none disabled:opacity-50",
|
||||||
|
context.flatCursor ? "cursor-default" : "cursor-grab data-dragging:cursor-grabbing",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
disabled={isDisabled}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const SortableOverlayContext = React.createContext(false);
|
||||||
|
|
||||||
|
const dropAnimation: DropAnimation = {
|
||||||
|
sideEffects: defaultDropAnimationSideEffects({
|
||||||
|
styles: {
|
||||||
|
active: {
|
||||||
|
opacity: "0.4",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
interface SortableOverlayProps extends Omit<React.ComponentProps<typeof DragOverlay>, "children"> {
|
||||||
|
container?: Element | DocumentFragment | null;
|
||||||
|
children?: ((params: { value: UniqueIdentifier }) => React.ReactNode) | React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SortableOverlay(props: SortableOverlayProps) {
|
||||||
|
const { container: containerProp, children, ...overlayProps } = props;
|
||||||
|
|
||||||
|
const context = useSortableContext(OVERLAY_NAME);
|
||||||
|
|
||||||
|
const [mounted, setMounted] = React.useState(false);
|
||||||
|
React.useLayoutEffect(() => setMounted(true), []);
|
||||||
|
|
||||||
|
const container = containerProp ?? (mounted ? globalThis.document?.body : null);
|
||||||
|
|
||||||
|
if (!container) return null;
|
||||||
|
|
||||||
|
return ReactDOM.createPortal(
|
||||||
|
<DragOverlay
|
||||||
|
dropAnimation={dropAnimation}
|
||||||
|
modifiers={context.modifiers}
|
||||||
|
className={cn(!context.flatCursor && "cursor-grabbing")}
|
||||||
|
{...overlayProps}
|
||||||
|
>
|
||||||
|
<SortableOverlayContext.Provider value={true}>
|
||||||
|
{context.activeId
|
||||||
|
? typeof children === "function"
|
||||||
|
? children({ value: context.activeId })
|
||||||
|
: children
|
||||||
|
: null}
|
||||||
|
</SortableOverlayContext.Provider>
|
||||||
|
</DragOverlay>,
|
||||||
|
container
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { SortableRoot as Sortable, SortableContent, SortableItem, SortableItemHandle, SortableOverlay };
|
||||||
48
src/components/UI/Tooltip.tsx
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
|
||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
function TooltipProvider({ delayDuration = 0, ...props }: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
|
||||||
|
return <TooltipPrimitive.Provider data-slot="tooltip-provider" delayDuration={delayDuration} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Tooltip({ ...props }: React.ComponentProps<typeof TooltipPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<TooltipProvider>
|
||||||
|
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
|
||||||
|
</TooltipProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TooltipTrigger({ ...props }: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
|
||||||
|
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function TooltipContent({
|
||||||
|
className,
|
||||||
|
sideOffset = 0,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<TooltipPrimitive.Portal>
|
||||||
|
<TooltipPrimitive.Content
|
||||||
|
data-slot="tooltip-content"
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
"animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 bg-text text-desktop-primary z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<TooltipPrimitive.Arrow className="bg-text fill-text z-50 size-2.5 translate-y-[calc(-50%-2px)] rotate-45 rounded-xs" />
|
||||||
|
</TooltipPrimitive.Content>
|
||||||
|
</TooltipPrimitive.Portal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger };
|
||||||
2
src/data/protectedRoutes.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
// protected routes
|
||||||
|
export const protectedRoutes = ["/panel", "/complaints/complaints-panel", "/app-complaints"];
|
||||||
46
src/data/sidebarMenu.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
export const menuItems: Menu[] = [
|
||||||
|
{ title: "تصاویر دوربین ها", href: "/road-cameras" },
|
||||||
|
{ title: "شکایات", href: "/complaints" },
|
||||||
|
{
|
||||||
|
title: "اخبار",
|
||||||
|
children: [
|
||||||
|
{ title: "آخرین وضعیت راه های کشور", href: "/news/latest-roads-state" },
|
||||||
|
{ title: "کارگاه های جاده ای", href: "/news/road-works" },
|
||||||
|
{ title: "انسدادها", href: "/news/obstruction-list" },
|
||||||
|
{ title: "محدودیت تردد", href: "/news/limitations" },
|
||||||
|
{ title: "محدودیت اعلامی پلیس", href: "/news/police-traffic-restrictions" },
|
||||||
|
{ title: "اطلاعیه های هواشناسی", href: "/news/road-weather" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
// {
|
||||||
|
// title: "راه و ترافیک",
|
||||||
|
// children: [
|
||||||
|
// { title: "طبقه بندی راه های کشور", href: "#" },
|
||||||
|
// { title: "داده های تردد شمار", href: "#" },
|
||||||
|
// ],
|
||||||
|
// },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const mobileMenuItems: Menu[] = [
|
||||||
|
{ title: "نقشه", href: "/" },
|
||||||
|
{ title: "تصاویر دوربین ها", href: "/road-cameras" },
|
||||||
|
{ title: "شکایات", href: "/complaints" },
|
||||||
|
{
|
||||||
|
title: "اخبار",
|
||||||
|
children: [
|
||||||
|
{ title: "آخرین وضعیت راه های کشور", href: "/news/latest-roads-state" },
|
||||||
|
{ title: "کارگاه های جاده ای", href: "/news/road-works" },
|
||||||
|
{ title: "انسدادها", href: "/news/obstruction-list" },
|
||||||
|
{ title: "محدودیت تردد", href: "/news/limitations" },
|
||||||
|
{ title: "محدودیت اعلامی پلیس", href: "/news/police-traffic-restrictions" },
|
||||||
|
{ title: "اطلاعیه های هواشناسی", href: "/news/road-weather" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
// {
|
||||||
|
// title: "راه و ترافیک",
|
||||||
|
// children: [
|
||||||
|
// { title: "طبقه بندی راه های کشور", href: "#" },
|
||||||
|
// { title: "داده های تردد شمار", href: "#" },
|
||||||
|
// ],
|
||||||
|
// },
|
||||||
|
];
|
||||||
104
src/hooks/useAuth.ts
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
import { useAuthStore } from "@/stores/useAuthStore";
|
||||||
|
import useUserStore from "@/stores/userStore";
|
||||||
|
import { POST_LOGOUT, POST_OTP, POST_USER_PASS_LOGIN, POST_VERIFY_OTP_LOGIN_AND_SIGNUP } from "@/utils/apiRoutes";
|
||||||
|
|
||||||
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
|
import useRequest from "./useRequest";
|
||||||
|
|
||||||
|
export function useAuth() {
|
||||||
|
const logoutClient = useUserStore((s) => s.logout);
|
||||||
|
const setIsAuthenticating = useAuthStore((s) => s.setIsAuthenticating);
|
||||||
|
const setTempPhoneNumber = useAuthStore((s) => s.setTempPhoneNumber);
|
||||||
|
const setAuthPanelState = useAuthStore((s) => s.setAuthPanelState);
|
||||||
|
const tempPhoneNumber = useAuthStore((s) => s.tempPhoneNumber);
|
||||||
|
|
||||||
|
const setToken = useUserStore((s) => s.setToken);
|
||||||
|
const setUser = useUserStore((s) => s.changeUser);
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
|
||||||
|
const requestServer = useRequest({ auth: true, notification: true });
|
||||||
|
|
||||||
|
async function requestOtp(phone_number: string, aythType: "login" | "signup") {
|
||||||
|
setIsAuthenticating(true);
|
||||||
|
try {
|
||||||
|
await requestServer(POST_OTP, "post", {
|
||||||
|
data: { phone_number },
|
||||||
|
});
|
||||||
|
setTempPhoneNumber(phone_number);
|
||||||
|
setAuthPanelState(aythType === "login" ? "loginOtp" : "signUpOtp");
|
||||||
|
} catch (error) {
|
||||||
|
console.error("OTP request failed:", error);
|
||||||
|
} finally {
|
||||||
|
setIsAuthenticating(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function VerifyOtpLoginAndSignup(otp: string) {
|
||||||
|
if (!tempPhoneNumber) return;
|
||||||
|
|
||||||
|
setIsAuthenticating(true);
|
||||||
|
try {
|
||||||
|
const response = await requestServer(POST_VERIFY_OTP_LOGIN_AND_SIGNUP, "post", {
|
||||||
|
data: {
|
||||||
|
phone_number: tempPhoneNumber,
|
||||||
|
otp,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const { token, user } = response.data;
|
||||||
|
setToken(token);
|
||||||
|
setUser(user);
|
||||||
|
|
||||||
|
const backUrl = searchParams.get("back_url");
|
||||||
|
if (backUrl) {
|
||||||
|
router.replace(backUrl);
|
||||||
|
} else {
|
||||||
|
router.replace("/");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("OTP verification failed:", error);
|
||||||
|
} finally {
|
||||||
|
setIsAuthenticating(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loginWithUserName(userName: string, password: string) {
|
||||||
|
setIsAuthenticating(true);
|
||||||
|
try {
|
||||||
|
const response = await requestServer(POST_USER_PASS_LOGIN, "post", {
|
||||||
|
data: {
|
||||||
|
username: userName,
|
||||||
|
password,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const { token, user } = response.data;
|
||||||
|
setToken(token);
|
||||||
|
setUser(user);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Login failed:", error);
|
||||||
|
} finally {
|
||||||
|
setIsAuthenticating(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function logout() {
|
||||||
|
setIsAuthenticating(true);
|
||||||
|
try {
|
||||||
|
await requestServer(POST_LOGOUT, "post");
|
||||||
|
logoutClient();
|
||||||
|
window.location.reload();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Logout failed:", error);
|
||||||
|
} finally {
|
||||||
|
setIsAuthenticating(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
logout,
|
||||||
|
requestOtp,
|
||||||
|
signIn: VerifyOtpLoginAndSignup,
|
||||||
|
loginWithUserName,
|
||||||
|
};
|
||||||
|
}
|
||||||
22
src/hooks/useDebouncedCallback.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { useCallback, useEffect, useRef } from "react";
|
||||||
|
|
||||||
|
export function useDebouncedCallback<T extends (...args: never[]) => void>(callback: T, delay: number) {
|
||||||
|
const timer = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
|
||||||
|
const debounced = useCallback(
|
||||||
|
(...args: Parameters<T>) => {
|
||||||
|
if (timer.current) clearTimeout(timer.current);
|
||||||
|
timer.current = setTimeout(() => callback(...args), delay);
|
||||||
|
},
|
||||||
|
[callback, delay]
|
||||||
|
);
|
||||||
|
|
||||||
|
const cancel = useCallback(() => {
|
||||||
|
if (timer.current) clearTimeout(timer.current);
|
||||||
|
timer.current = null;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => cancel, [cancel]);
|
||||||
|
|
||||||
|
return { debounced, cancel };
|
||||||
|
}
|
||||||
17
src/hooks/useDevice.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
export default function useDevice() {
|
||||||
|
const [isMobile, setIsMobile] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const checkIsMobile = () => {
|
||||||
|
setIsMobile(window.innerWidth < 768);
|
||||||
|
};
|
||||||
|
|
||||||
|
checkIsMobile();
|
||||||
|
window.addEventListener("resize", checkIsMobile);
|
||||||
|
return () => window.removeEventListener("resize", checkIsMobile);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return isMobile;
|
||||||
|
}
|
||||||
92
src/hooks/useRequest.ts
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
"use client";
|
||||||
|
import axios from "axios";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import useUserStore from "@/stores/userStore";
|
||||||
|
import ToastStore from "@/stores/useToastStore";
|
||||||
|
import { Notifications } from "@/notifications";
|
||||||
|
|
||||||
|
export interface RequestOptions {
|
||||||
|
auth?: boolean;
|
||||||
|
data?: Record<string, string> | FormData;
|
||||||
|
requestOptions?: {
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
signal?: AbortSignal;
|
||||||
|
[key: string]: any;
|
||||||
|
};
|
||||||
|
notification?: boolean;
|
||||||
|
pending?: boolean;
|
||||||
|
success?: {
|
||||||
|
notification: {
|
||||||
|
show: boolean;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
failed?: {
|
||||||
|
notification: {
|
||||||
|
show: boolean;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultOptions: RequestOptions = {
|
||||||
|
auth: false,
|
||||||
|
data: {},
|
||||||
|
requestOptions: {
|
||||||
|
headers: {},
|
||||||
|
},
|
||||||
|
notification: true,
|
||||||
|
pending: true,
|
||||||
|
success: {
|
||||||
|
notification: {
|
||||||
|
show: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
failed: {
|
||||||
|
notification: {
|
||||||
|
show: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const useRequest = (initOptions: RequestOptions) => {
|
||||||
|
const t = useTranslations();
|
||||||
|
const { token, clearToken } = useUserStore();
|
||||||
|
const { pushToastList, dismissToastList } = ToastStore();
|
||||||
|
|
||||||
|
let _options = { ...defaultOptions, ...initOptions };
|
||||||
|
|
||||||
|
function requestServer(url: string, method: string = "get", options?: RequestOptions) {
|
||||||
|
_options = { ..._options, ...options };
|
||||||
|
|
||||||
|
if (_options.auth) {
|
||||||
|
const isFormData = _options.data instanceof FormData;
|
||||||
|
_options = {
|
||||||
|
..._options,
|
||||||
|
requestOptions: {
|
||||||
|
...(_options.requestOptions || {}),
|
||||||
|
headers: {
|
||||||
|
...(_options.requestOptions?.headers || {}),
|
||||||
|
...(isFormData ? {} : { "Content-Type": "application/json" }),
|
||||||
|
authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return Notifications(
|
||||||
|
axios({
|
||||||
|
url,
|
||||||
|
method,
|
||||||
|
data: _options.data,
|
||||||
|
..._options.requestOptions,
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
t,
|
||||||
|
handlers: { pushToastList, dismissToastList, clearToken },
|
||||||
|
options: _options,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return requestServer;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useRequest;
|
||||||
12
src/i18n/request.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { cookies } from "next/headers";
|
||||||
|
import { getRequestConfig } from "next-intl/server";
|
||||||
|
|
||||||
|
export default getRequestConfig(async () => {
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
const locale = cookieStore.get("language")?.value || "fa";
|
||||||
|
|
||||||
|
return {
|
||||||
|
locale,
|
||||||
|
messages: (await import(`../../messages/${locale}.json`)).default,
|
||||||
|
};
|
||||||
|
});
|
||||||
66
src/lib/compose-refs.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
/**
|
||||||
|
* @see https://github.com/radix-ui/primitives/blob/main/packages/react/compose-refs/src/compose-refs.tsx
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
type PossibleRef<T> = React.Ref<T> | undefined;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set a given ref to a given value
|
||||||
|
* This utility takes care of different types of refs: callback refs and RefObject(s)
|
||||||
|
*/
|
||||||
|
function setRef<T>(ref: PossibleRef<T>, value: T) {
|
||||||
|
if (typeof ref === "function") {
|
||||||
|
return ref(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ref !== null && ref !== undefined) {
|
||||||
|
ref.current = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A utility to compose multiple refs together
|
||||||
|
* Accepts callback refs and RefObject(s)
|
||||||
|
*/
|
||||||
|
function composeRefs<T>(...refs: PossibleRef<T>[]): React.RefCallback<T> {
|
||||||
|
return (node) => {
|
||||||
|
let hasCleanup = false;
|
||||||
|
const cleanups = refs.map((ref) => {
|
||||||
|
const cleanup = setRef(ref, node);
|
||||||
|
if (!hasCleanup && typeof cleanup === "function") {
|
||||||
|
hasCleanup = true;
|
||||||
|
}
|
||||||
|
return cleanup;
|
||||||
|
});
|
||||||
|
|
||||||
|
// React <19 will log an error to the console if a callback ref returns a
|
||||||
|
// value. We don't use ref cleanups internally so this will only happen if a
|
||||||
|
// user's ref callback returns a value, which we only expect if they are
|
||||||
|
// using the cleanup functionality added in React 19.
|
||||||
|
if (hasCleanup) {
|
||||||
|
return () => {
|
||||||
|
for (let i = 0; i < cleanups.length; i++) {
|
||||||
|
const cleanup = cleanups[i];
|
||||||
|
if (typeof cleanup === "function") {
|
||||||
|
cleanup();
|
||||||
|
} else {
|
||||||
|
setRef(refs[i], null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A custom hook that composes multiple refs
|
||||||
|
* Accepts callback refs and RefObject(s)
|
||||||
|
*/
|
||||||
|
function useComposedRefs<T>(...refs: PossibleRef<T>[]): React.RefCallback<T> {
|
||||||
|
// biome-ignore lint/correctness/useExhaustiveDependencies: we want to memoize by all values
|
||||||
|
return React.useCallback(composeRefs(...refs), refs);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { composeRefs, useComposedRefs };
|
||||||
6
src/lib/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { clsx, type ClassValue } from "clsx";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs));
|
||||||
|
}
|
||||||
133
src/notifications/index.tsx
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
import toast from "react-hot-toast";
|
||||||
|
import { AxiosResponse } from "axios";
|
||||||
|
|
||||||
|
type ToastType = "pending" | "error" | "warning" | "success";
|
||||||
|
type TranslationFunction = (key: string) => string;
|
||||||
|
|
||||||
|
interface ToastHandlers {
|
||||||
|
pushToastList: (type: ToastType, id: string) => void;
|
||||||
|
dismissToastList: (types: ToastType[]) => void;
|
||||||
|
clearToken: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RequestOptions {
|
||||||
|
auth?: boolean;
|
||||||
|
data?: Record<string, string> | FormData;
|
||||||
|
requestOptions?: {
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
signal?: AbortSignal;
|
||||||
|
[key: string]: any;
|
||||||
|
};
|
||||||
|
notification?: boolean;
|
||||||
|
pending?: boolean;
|
||||||
|
success?: {
|
||||||
|
notification: {
|
||||||
|
show: boolean;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
failed?: {
|
||||||
|
notification: {
|
||||||
|
show: boolean;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ToastPromiseOptions {
|
||||||
|
t: TranslationFunction;
|
||||||
|
handlers: ToastHandlers;
|
||||||
|
options: RequestOptions;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Notifications = (promise: Promise<AxiosResponse>, { t, handlers, options }: ToastPromiseOptions) => {
|
||||||
|
const { pushToastList, dismissToastList, clearToken } = handlers;
|
||||||
|
|
||||||
|
const showNotification = options.notification ?? true;
|
||||||
|
const showPending = options.pending ?? true;
|
||||||
|
const showSuccess = options.success?.notification?.show ?? true;
|
||||||
|
const showFailed = options.failed?.notification?.show ?? true;
|
||||||
|
|
||||||
|
if (!showNotification) return promise;
|
||||||
|
|
||||||
|
return toast.promise(
|
||||||
|
promise,
|
||||||
|
{
|
||||||
|
loading: showPending ? t("Notifications.pending") : "",
|
||||||
|
success: (response: AxiosResponse) => {
|
||||||
|
if (showSuccess) {
|
||||||
|
dismissToastList(["pending", "warning", "error", "success"]);
|
||||||
|
pushToastList("success", String(response.status));
|
||||||
|
}
|
||||||
|
return t("Notifications.success");
|
||||||
|
},
|
||||||
|
error: (error: any) => {
|
||||||
|
if (showFailed) {
|
||||||
|
dismissToastList(["pending", "warning", "error", "success"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!showFailed) return t("Notifications.error");
|
||||||
|
|
||||||
|
let message = t("Notifications.error");
|
||||||
|
|
||||||
|
if (error.response) {
|
||||||
|
const status = error.response.status;
|
||||||
|
const responseData = error.response.data;
|
||||||
|
|
||||||
|
// build default message with status code
|
||||||
|
message = `${status} - ${t("Notifications.error")}`;
|
||||||
|
|
||||||
|
if (status >= 500 && status <= 599) {
|
||||||
|
pushToastList("warning", String(status));
|
||||||
|
} else if (status >= 400 && status <= 499) {
|
||||||
|
switch (status) {
|
||||||
|
case 401:
|
||||||
|
clearToken();
|
||||||
|
pushToastList("error", String(status));
|
||||||
|
break;
|
||||||
|
case 422:
|
||||||
|
// append validation messages
|
||||||
|
if (responseData?.message) {
|
||||||
|
if (Array.isArray(responseData.message)) {
|
||||||
|
message = responseData.message.join(", ");
|
||||||
|
} else {
|
||||||
|
message = responseData.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (responseData?.errors) {
|
||||||
|
const errs: string[] = [];
|
||||||
|
Object.keys(responseData.errors).forEach((key) => {
|
||||||
|
const msg = responseData.errors[key]?.[0];
|
||||||
|
if (msg) errs.push(msg);
|
||||||
|
});
|
||||||
|
if (errs.length) {
|
||||||
|
message = errs.join(", ");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 429:
|
||||||
|
message = `${status} - Too many requests`;
|
||||||
|
pushToastList("error", String(status));
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
pushToastList("error", String(status));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
pushToastList("error", t("Notifications.error"));
|
||||||
|
}
|
||||||
|
} else if (error.request) {
|
||||||
|
message = t("Notifications.error") + " (No response)";
|
||||||
|
pushToastList("error", message);
|
||||||
|
} else {
|
||||||
|
message = t("Notifications.error") + " (Request failed)";
|
||||||
|
pushToastList("error", message);
|
||||||
|
}
|
||||||
|
|
||||||
|
return message;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
position: "top-center",
|
||||||
|
duration: 4000,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
40
src/providers/ClientAppProvider.tsx
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { AnimatePresence, motion } from "framer-motion";
|
||||||
|
import { ThemeProvider } from "next-themes";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
export function ClientAppProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const [mounted, setMounted] = useState(false);
|
||||||
|
|
||||||
|
// wait for hydration
|
||||||
|
useEffect(() => {
|
||||||
|
setMounted(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ThemeProvider attribute="data-theme" defaultTheme="dark" enableSystem>
|
||||||
|
<AnimatePresence mode="wait">
|
||||||
|
{!mounted ? (
|
||||||
|
<motion.div
|
||||||
|
key="splash"
|
||||||
|
className="bg-map-bg flex h-dvh w-dvw items-center justify-center"
|
||||||
|
initial={{ opacity: 1 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<motion.div
|
||||||
|
key="app"
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
transition={{ duration: 0.3, ease: "easeOut" }}
|
||||||
|
className="bg-map-bg"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</ThemeProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
25
src/providers/DeviceProvider.tsx
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useEffect, useState, useEffectEvent } from "react";
|
||||||
|
import { useDeviceStore } from "@/stores/useDeviceStore";
|
||||||
|
import useDevice from "@/hooks/useDevice";
|
||||||
|
|
||||||
|
export default function DeviceProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const isMobileDevice = useDevice();
|
||||||
|
const setIsMobile = useDeviceStore((state) => state.setIsMobile);
|
||||||
|
|
||||||
|
const [ready, setReady] = useState(false);
|
||||||
|
|
||||||
|
const syncDevice = useEffectEvent((isMobile: boolean) => {
|
||||||
|
setIsMobile(isMobile);
|
||||||
|
setReady(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
syncDevice(isMobileDevice);
|
||||||
|
}, [isMobileDevice]);
|
||||||
|
|
||||||
|
if (!ready) return null;
|
||||||
|
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
6
src/providers/MapProviderComponent.tsx
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
"use client";
|
||||||
|
import { MapProvider } from "@vis.gl/react-maplibre";
|
||||||
|
|
||||||
|
export default function MapProviderComponent({ children }: { children: React.ReactNode }) {
|
||||||
|
return <MapProvider>{children}</MapProvider>;
|
||||||
|
}
|
||||||
26
src/proxy.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { protectedRoutes } from "./data/protectedRoutes";
|
||||||
|
|
||||||
|
export function proxy(req: NextRequest) {
|
||||||
|
const token = req.cookies.get("_token")?.value;
|
||||||
|
const { pathname } = req.nextUrl;
|
||||||
|
const needsAuth = protectedRoutes.some((route) => pathname.startsWith(route));
|
||||||
|
|
||||||
|
if (pathname.startsWith("/auth") && token) {
|
||||||
|
return NextResponse.redirect(new URL("/", req.url));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (needsAuth && !token) {
|
||||||
|
const loginUrl = new URL("/auth", req.url);
|
||||||
|
|
||||||
|
loginUrl.searchParams.set("back_url", pathname);
|
||||||
|
|
||||||
|
return NextResponse.redirect(loginUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.next();
|
||||||
|
}
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
matcher: ["/auth", "/app-complaints", "/panel/:path*", "/complaints/complaints-panel"],
|
||||||
|
};
|
||||||
100
src/stores/LocationStore.ts
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
import { create } from "zustand";
|
||||||
|
|
||||||
|
export type LatLng = { lat: number; lng: number };
|
||||||
|
export type Mode = "origin" | "destination" | "routing";
|
||||||
|
export type LocationPoint = {
|
||||||
|
id: string;
|
||||||
|
locationType: "origin" | "destination";
|
||||||
|
coordinate: LatLng | null;
|
||||||
|
uniqueId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type LocationState = {
|
||||||
|
userLocation: LatLng | null;
|
||||||
|
origin: LatLng | null;
|
||||||
|
destination: LatLng | null;
|
||||||
|
locationPoints: LocationPoint[];
|
||||||
|
currentStep: Mode;
|
||||||
|
|
||||||
|
setUserLocation: (val: LatLng) => void;
|
||||||
|
setLocation: (mode: Mode, val: LatLng | null) => void;
|
||||||
|
setLocationPoints: (val: LocationPoint[]) => void;
|
||||||
|
setStep: (step: Mode) => void;
|
||||||
|
reset: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildLocationPoints = (origin: LatLng | null, destination: LatLng | null): LocationPoint[] => {
|
||||||
|
const points: LocationPoint[] = [];
|
||||||
|
|
||||||
|
points.push({
|
||||||
|
id: "origin",
|
||||||
|
locationType: "origin",
|
||||||
|
coordinate: origin,
|
||||||
|
uniqueId: "0",
|
||||||
|
});
|
||||||
|
points.push({
|
||||||
|
id: "destination",
|
||||||
|
locationType: "destination",
|
||||||
|
coordinate: destination,
|
||||||
|
uniqueId: "1",
|
||||||
|
});
|
||||||
|
|
||||||
|
return points;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useLocationStore = create<LocationState>((set, get) => ({
|
||||||
|
userLocation: null,
|
||||||
|
origin: null,
|
||||||
|
destination: null,
|
||||||
|
locationPoints: [
|
||||||
|
{
|
||||||
|
id: "origin",
|
||||||
|
locationType: "origin",
|
||||||
|
coordinate: null,
|
||||||
|
uniqueId: "0",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "destination",
|
||||||
|
locationType: "destination",
|
||||||
|
coordinate: null,
|
||||||
|
uniqueId: "1",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
currentStep: "destination",
|
||||||
|
|
||||||
|
setUserLocation: (val) => set({ userLocation: val }),
|
||||||
|
setLocation: (mode, val) =>
|
||||||
|
set((state) => {
|
||||||
|
const updated = { ...state, [mode]: val };
|
||||||
|
return {
|
||||||
|
...updated,
|
||||||
|
locationPoints: buildLocationPoints(updated.origin, updated.destination),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
|
||||||
|
setLocationPoints: (val) => {
|
||||||
|
// Create new points in reordered order, but reassign types/ids based on positions
|
||||||
|
const newPoints: LocationPoint[] = val.map((point, index, arr) => ({
|
||||||
|
uniqueId: point.uniqueId, // Preserve persistent ID
|
||||||
|
id: index === 0 ? "origin" : "destination",
|
||||||
|
locationType: index === 0 ? "origin" : "destination",
|
||||||
|
coordinate: point.coordinate,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const origin = newPoints[0]?.coordinate ?? null;
|
||||||
|
const destination = newPoints.at(-1)?.coordinate ?? null;
|
||||||
|
|
||||||
|
set({
|
||||||
|
locationPoints: newPoints,
|
||||||
|
origin,
|
||||||
|
destination,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
setStep: (step) => set({ currentStep: step }),
|
||||||
|
reset: () =>
|
||||||
|
set({
|
||||||
|
origin: null,
|
||||||
|
destination: null,
|
||||||
|
currentStep: "destination",
|
||||||
|
}),
|
||||||
|
}));
|
||||||
46
src/stores/cityStore.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { create } from "zustand";
|
||||||
|
import { GET_CITIES } from "@/utils/apiRoutes";
|
||||||
|
|
||||||
|
interface City {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CityStore {
|
||||||
|
city: string;
|
||||||
|
selectedProvinceID: string | null;
|
||||||
|
cities: City[];
|
||||||
|
setCity: (value: string) => void;
|
||||||
|
setCities: (data: City[]) => void;
|
||||||
|
setSelectedProvinceID: (value: string) => void;
|
||||||
|
fetchCities: (provinceId: string) => Promise<void>;
|
||||||
|
loadCities: boolean;
|
||||||
|
errorCities: null | string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const useCityStore = create<CityStore>((set, get) => ({
|
||||||
|
city: "",
|
||||||
|
selectedProvinceID: null,
|
||||||
|
cities: [],
|
||||||
|
loadCities: false,
|
||||||
|
errorCities: null,
|
||||||
|
setCity: (city) => set({ city }),
|
||||||
|
setSelectedProvinceID: (selectedProvinceID) => set({ selectedProvinceID }),
|
||||||
|
setCities: (cities) => set({ cities }),
|
||||||
|
fetchCities: async (provinceId: string) => {
|
||||||
|
if (!provinceId) return;
|
||||||
|
set({ loadCities: true, errorCities: null });
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${GET_CITIES}/${provinceId}`);
|
||||||
|
const data = await res.json();
|
||||||
|
set({ cities: data.data.cities });
|
||||||
|
} catch (e) {
|
||||||
|
console.error("خطا در دریافت شهرها", e);
|
||||||
|
set({ errorCities: "خطا در دریافت شهرها" });
|
||||||
|
} finally {
|
||||||
|
set({ loadCities: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
export default useCityStore;
|
||||||
42
src/stores/provinceStore.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { create } from "zustand";
|
||||||
|
import { GET_PROVINCES } from "@/utils/apiRoutes";
|
||||||
|
|
||||||
|
interface Province {
|
||||||
|
id: number;
|
||||||
|
name_fa: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProvinceStore {
|
||||||
|
province: string;
|
||||||
|
provinces: Province[];
|
||||||
|
setProvince: (value: string) => void;
|
||||||
|
setProvinces: (data: Province[]) => void;
|
||||||
|
fetchProvinces: () => Promise<void>;
|
||||||
|
loadProvinces: boolean;
|
||||||
|
errorProvinces: null | string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const useProvinceStore = create<ProvinceStore>((set, get) => ({
|
||||||
|
province: "",
|
||||||
|
provinces: [],
|
||||||
|
loadProvinces: false,
|
||||||
|
errorProvinces: null,
|
||||||
|
setProvince: (province) => set({ province }),
|
||||||
|
setProvinces: (provinces) => set({ provinces }),
|
||||||
|
fetchProvinces: async () => {
|
||||||
|
if (get().provinces.length) return;
|
||||||
|
set({ loadProvinces: true });
|
||||||
|
try {
|
||||||
|
const res = await fetch(GET_PROVINCES);
|
||||||
|
const data = await res.json();
|
||||||
|
set({ provinces: data.data.provinces });
|
||||||
|
} catch (e) {
|
||||||
|
console.error("خطا در دریافت استانها", e);
|
||||||
|
set({ errorProvinces: "خطا در دریافت استان ها" });
|
||||||
|
} finally {
|
||||||
|
set({ loadProvinces: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
export default useProvinceStore;
|
||||||
30
src/stores/useAuthStore.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { create } from "zustand";
|
||||||
|
|
||||||
|
export type AuthPanelStateType =
|
||||||
|
| "loginWithPhoneNumber"
|
||||||
|
| "loginWithUserName"
|
||||||
|
| "signUp"
|
||||||
|
| "signUpOtp"
|
||||||
|
| "loginOtp"
|
||||||
|
| "startAuth";
|
||||||
|
|
||||||
|
type AuthStore = {
|
||||||
|
authPanelState: AuthPanelStateType;
|
||||||
|
setAuthPanelState: (state: AuthPanelStateType) => void;
|
||||||
|
|
||||||
|
tempPhoneNumber: string | null;
|
||||||
|
setTempPhoneNumber: (phone: string | null) => void;
|
||||||
|
|
||||||
|
isAuthenticating: boolean;
|
||||||
|
setIsAuthenticating: (isAuthenticating: boolean) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useAuthStore = create<AuthStore>((set) => ({
|
||||||
|
authPanelState: "loginWithPhoneNumber", // Default
|
||||||
|
tempPhoneNumber: null,
|
||||||
|
isAuthenticating: false,
|
||||||
|
|
||||||
|
setAuthPanelState: (state) => set({ authPanelState: state }),
|
||||||
|
setTempPhoneNumber: (phone) => set({ tempPhoneNumber: phone }),
|
||||||
|
setIsAuthenticating: (isAuthenticating) => set({ isAuthenticating }),
|
||||||
|
}));
|
||||||
22
src/stores/useBottomSheetStore.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { ReactNode } from "react";
|
||||||
|
import { create } from "zustand";
|
||||||
|
|
||||||
|
interface BottomSheetOptions {
|
||||||
|
interactiveBackground?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BottomSheetState {
|
||||||
|
isOpen: boolean;
|
||||||
|
content: ReactNode | null;
|
||||||
|
options: BottomSheetOptions;
|
||||||
|
openBottomSheet: (content: ReactNode, options?: BottomSheetOptions) => void;
|
||||||
|
closeBottomSheet: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useBottomSheetStore = create<BottomSheetState>((set) => ({
|
||||||
|
isOpen: false,
|
||||||
|
content: null,
|
||||||
|
options: {},
|
||||||
|
openBottomSheet: (content, options) => set({ isOpen: true, content, options }),
|
||||||
|
closeBottomSheet: () => set({ isOpen: false, content: null, options: {} }),
|
||||||
|
}));
|
||||||
11
src/stores/useDeviceStore.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { create } from "zustand";
|
||||||
|
|
||||||
|
interface DeviceState {
|
||||||
|
isMobile: boolean;
|
||||||
|
setIsMobile: (isMobile: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useDeviceStore = create<DeviceState>((set) => ({
|
||||||
|
isMobile: false,
|
||||||
|
setIsMobile: (isMobile) => set({ isMobile }),
|
||||||
|
}));
|
||||||
37
src/stores/useLanguageStore.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { create } from "zustand";
|
||||||
|
import Cookies from "js-cookie";
|
||||||
|
import { Language } from "@/types/language.types";
|
||||||
|
|
||||||
|
interface LanguageState {
|
||||||
|
localization: Language;
|
||||||
|
language_lists: Language[];
|
||||||
|
changeLanguage: (languageKey: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useLanguageStore = create<LanguageState>((set, get) => ({
|
||||||
|
language_lists: [
|
||||||
|
{
|
||||||
|
id: 0,
|
||||||
|
key: "fa",
|
||||||
|
language: "فارسی",
|
||||||
|
direction: "rtl",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
localization: {
|
||||||
|
id: 0,
|
||||||
|
key: "fa",
|
||||||
|
language: "فارسی",
|
||||||
|
direction: "rtl",
|
||||||
|
},
|
||||||
|
|
||||||
|
changeLanguage: (languageKey) => {
|
||||||
|
const currentState = get();
|
||||||
|
const selectedLanguage = currentState.language_lists.find((language) => language.key === languageKey);
|
||||||
|
|
||||||
|
if (selectedLanguage) {
|
||||||
|
Cookies.set("language", languageKey, { expires: 365, secure: true });
|
||||||
|
set({ localization: selectedLanguage });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}));
|
||||||
16
src/stores/useModalStore.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { create } from "zustand";
|
||||||
|
import { ReactNode } from "react";
|
||||||
|
|
||||||
|
interface ModalState {
|
||||||
|
isOpen: boolean;
|
||||||
|
content: ReactNode | null;
|
||||||
|
openModal: (content: ReactNode) => void;
|
||||||
|
closeModal: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useModalStore = create<ModalState>((set) => ({
|
||||||
|
isOpen: false,
|
||||||
|
content: null,
|
||||||
|
openModal: (content) => set({ isOpen: true, content }),
|
||||||
|
closeModal: () => set({ isOpen: false, content: null }),
|
||||||
|
}));
|
||||||
51
src/stores/useToastStore.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
"use client";
|
||||||
|
import { create } from "zustand";
|
||||||
|
import { toast } from "react-hot-toast";
|
||||||
|
|
||||||
|
export type ToastType = "pending" | "error" | "warning" | "success";
|
||||||
|
|
||||||
|
interface ToastStoreState {
|
||||||
|
toastLists: {
|
||||||
|
pending: string[];
|
||||||
|
error: string[];
|
||||||
|
warning: string[];
|
||||||
|
success: string[];
|
||||||
|
};
|
||||||
|
pushToastList: (toast_type: ToastType, toast_id: string) => void;
|
||||||
|
dismissToastList: (toast_types: ToastType[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ToastStore = create<ToastStoreState>((set) => ({
|
||||||
|
toastLists: {
|
||||||
|
pending: [],
|
||||||
|
error: [],
|
||||||
|
warning: [],
|
||||||
|
success: [],
|
||||||
|
},
|
||||||
|
|
||||||
|
pushToastList: (toast_type, toast_id) =>
|
||||||
|
set((state) => ({
|
||||||
|
toastLists: {
|
||||||
|
...state.toastLists,
|
||||||
|
[toast_type]: [...state.toastLists[toast_type], toast_id],
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
|
||||||
|
dismissToastList: (toast_types) =>
|
||||||
|
set((state) => {
|
||||||
|
toast_types.forEach((type) => {
|
||||||
|
state.toastLists[type].forEach((id) => {
|
||||||
|
toast.dismiss(id);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const newToastLists = { ...state.toastLists };
|
||||||
|
toast_types.forEach((type) => {
|
||||||
|
newToastLists[type] = [];
|
||||||
|
});
|
||||||
|
|
||||||
|
return { toastLists: newToastLists };
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export default ToastStore;
|
||||||
157
src/stores/userStore.ts
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
"use client";
|
||||||
|
import { GET_USER_ROUTE } from "@/utils/apiRoutes";
|
||||||
|
import axios from "axios";
|
||||||
|
import Cookies from "js-cookie";
|
||||||
|
import { create } from "zustand";
|
||||||
|
import { persist } from "zustand/middleware";
|
||||||
|
|
||||||
|
interface User {
|
||||||
|
id: number;
|
||||||
|
first_name: string | null;
|
||||||
|
last_name: string | null;
|
||||||
|
phone_number: string;
|
||||||
|
email: string | null;
|
||||||
|
gender: "male" | "female" | null;
|
||||||
|
job_field: string | null;
|
||||||
|
province_id: number | null;
|
||||||
|
province_fa: string | null;
|
||||||
|
city_id: number | null;
|
||||||
|
city_fa: string | null;
|
||||||
|
national_code: string | null;
|
||||||
|
vehicle_type: string | null;
|
||||||
|
is_valid: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UserStoreState {
|
||||||
|
isAuth: boolean;
|
||||||
|
errorState: boolean;
|
||||||
|
userChangedLanguage: boolean;
|
||||||
|
initAuthState: boolean;
|
||||||
|
token: string | null;
|
||||||
|
user: User;
|
||||||
|
clearUser: () => void;
|
||||||
|
changeUser: (user: User) => void;
|
||||||
|
changeUserLanguage: (language: string) => void;
|
||||||
|
changeAuthState: (isAuth: boolean) => void;
|
||||||
|
changeInitAuth: (initAuthState: boolean) => void;
|
||||||
|
changeLanguageState: (userChangedLanguage: boolean) => void;
|
||||||
|
clearToken: () => void;
|
||||||
|
logout: () => Promise<void>;
|
||||||
|
setToken: (token: string) => void;
|
||||||
|
getUser: () => Promise<void>;
|
||||||
|
initialize: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultUser: User = {
|
||||||
|
id: 0,
|
||||||
|
first_name: null,
|
||||||
|
last_name: null,
|
||||||
|
phone_number: "",
|
||||||
|
email: "",
|
||||||
|
gender: null,
|
||||||
|
job_field: null,
|
||||||
|
province_id: null,
|
||||||
|
province_fa: null,
|
||||||
|
city_id: null,
|
||||||
|
city_fa: null,
|
||||||
|
national_code: null,
|
||||||
|
vehicle_type: null,
|
||||||
|
is_valid: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const useUserStore = create<UserStoreState>()(
|
||||||
|
persist(
|
||||||
|
(set, get) => ({
|
||||||
|
isAuth: false,
|
||||||
|
userChangedLanguage: false,
|
||||||
|
initAuthState: false,
|
||||||
|
errorState: false,
|
||||||
|
token: null,
|
||||||
|
user: defaultUser,
|
||||||
|
|
||||||
|
clearUser: () => set({ user: defaultUser }),
|
||||||
|
|
||||||
|
changeUser: (user: User) => set({ user, isAuth: true }),
|
||||||
|
|
||||||
|
changeUserLanguage: (language: string) =>
|
||||||
|
set((state) => ({
|
||||||
|
user: { ...state.user, user_language: language },
|
||||||
|
})),
|
||||||
|
|
||||||
|
changeAuthState: (isAuth: boolean) => set({ isAuth }),
|
||||||
|
|
||||||
|
changeInitAuth: (initAuthState: boolean) => set({ initAuthState }),
|
||||||
|
|
||||||
|
changeLanguageState: (userChangedLanguage: boolean) => set({ userChangedLanguage }),
|
||||||
|
|
||||||
|
clearToken: () => {
|
||||||
|
set({ token: null, isAuth: false });
|
||||||
|
set({ initAuthState: true });
|
||||||
|
Cookies.remove("_token");
|
||||||
|
},
|
||||||
|
|
||||||
|
setToken: (token: string) => {
|
||||||
|
set({ token, initAuthState: true });
|
||||||
|
Cookies.set("_token", token, { sameSite: "strict" });
|
||||||
|
},
|
||||||
|
|
||||||
|
logout: async () => {
|
||||||
|
get().clearUser();
|
||||||
|
get().changeAuthState(false);
|
||||||
|
get().changeInitAuth(true);
|
||||||
|
Cookies.remove("_token");
|
||||||
|
set({ isAuth: false });
|
||||||
|
},
|
||||||
|
|
||||||
|
getUser: async () => {
|
||||||
|
const token = get().token;
|
||||||
|
if (!token) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { data } = await axios.get(GET_USER_ROUTE, {
|
||||||
|
headers: { authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
set({
|
||||||
|
user: data,
|
||||||
|
isAuth: true,
|
||||||
|
initAuthState: true,
|
||||||
|
errorState: false,
|
||||||
|
});
|
||||||
|
Cookies.set("_token", token, { sameSite: "strict" });
|
||||||
|
} catch (error: unknown) {
|
||||||
|
if (axios.isAxiosError(error) && error.response?.status === 401) {
|
||||||
|
get().clearToken();
|
||||||
|
}
|
||||||
|
set({
|
||||||
|
isAuth: false,
|
||||||
|
initAuthState: true,
|
||||||
|
errorState: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
initialize: async () => {
|
||||||
|
const token = get().token;
|
||||||
|
if (!token) {
|
||||||
|
get().clearUser();
|
||||||
|
get().changeAuthState(false);
|
||||||
|
get().changeInitAuth(true);
|
||||||
|
get().changeLanguageState(false);
|
||||||
|
get().clearToken();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await get().getUser();
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
name: "user-storage",
|
||||||
|
partialize: (state) => ({
|
||||||
|
token: state.token,
|
||||||
|
user: state.user,
|
||||||
|
isAuth: state.isAuth,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
export default useUserStore;
|
||||||
116
src/styles/globals.css
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after,
|
||||||
|
*::-webkit-scrollbar {
|
||||||
|
box-sizing: border-box;
|
||||||
|
direction: rtl;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
*::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer utilities {
|
||||||
|
.bg-gradient-center-to-left {
|
||||||
|
background: linear-gradient(to left, rgba(0, 0, 0, 0.3) 50%, rgba(0, 0, 0, 0.5) 100%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/* disable pull to refresh */
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
overscroll-behavior: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: "Ravi", sans-serif;
|
||||||
|
-moz-font-feature-settings: "ss02";
|
||||||
|
-webkit-font-feature-settings: "ss02";
|
||||||
|
font-feature-settings: "ss02";
|
||||||
|
background-color: #fff;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
font-family: "Ravi", sans-serif;
|
||||||
|
-moz-font-feature-settings: "ss02";
|
||||||
|
-webkit-font-feature-settings: "ss02";
|
||||||
|
font-feature-settings: "ss02";
|
||||||
|
}
|
||||||
|
|
||||||
|
body,
|
||||||
|
main,
|
||||||
|
div,
|
||||||
|
section,
|
||||||
|
header,
|
||||||
|
footer,
|
||||||
|
button,
|
||||||
|
input {
|
||||||
|
transition:
|
||||||
|
background-color 0.3s ease,
|
||||||
|
color 0.3s ease,
|
||||||
|
border-color 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
width: 100%;
|
||||||
|
@media (width >= 40rem /* 640px */) {
|
||||||
|
max-width: 95%;
|
||||||
|
}
|
||||||
|
@media (width >= 48rem /* 768px */) {
|
||||||
|
max-width: 95%;
|
||||||
|
}
|
||||||
|
@media (width >= 64rem /* 1024px */) {
|
||||||
|
max-width: 95%;
|
||||||
|
}
|
||||||
|
@media (width >= 80rem /* 1280px */) {
|
||||||
|
max-width: 95%;
|
||||||
|
}
|
||||||
|
@media (width >= 96rem /* 1536px */) {
|
||||||
|
max-width: 90%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--background: #ffffff;
|
||||||
|
--foreground: #171717;
|
||||||
|
}
|
||||||
|
|
||||||
|
@theme inline {
|
||||||
|
--color-background: var(--background);
|
||||||
|
--color-foreground: var(--foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root {
|
||||||
|
--background: #0a0a0a;
|
||||||
|
--foreground: #ededed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background: var(--background);
|
||||||
|
color: var(--foreground);
|
||||||
|
font-family: Arial, Helvetica, sans-serif;
|
||||||
|
}
|
||||||
|
.maplibregl-popup-content {
|
||||||
|
width: 0px !important;
|
||||||
|
height: 0px !important;
|
||||||
|
background-color: transparent !important;
|
||||||
|
position: relative !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.maplibregl-popup-tip {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
6
src/types/language.types.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export interface Language {
|
||||||
|
id: number;
|
||||||
|
key: string;
|
||||||
|
language: string;
|
||||||
|
direction: "rtl" | "ltr";
|
||||||
|
}
|
||||||
5
src/types/sidebar-menu.types.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
type Menu = {
|
||||||
|
title: string;
|
||||||
|
href?: string;
|
||||||
|
children?: Menu[];
|
||||||
|
};
|
||||||
4
src/types/types.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export interface LatLng {
|
||||||
|
lat: number;
|
||||||
|
lng: number;
|
||||||
|
}
|
||||||
16
src/utils/ToastProvider.tsx
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import toast, { Toaster, useToasterStore } from "react-hot-toast";
|
||||||
|
|
||||||
|
export default function ToastProvider() {
|
||||||
|
const { toasts } = useToasterStore();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
toasts
|
||||||
|
.filter((t) => t.visible)
|
||||||
|
.filter((_, i) => i >= 3)
|
||||||
|
.forEach((t) => toast.dismiss(t.id));
|
||||||
|
}, [toasts]);
|
||||||
|
return <Toaster />;
|
||||||
|
}
|
||||||
15
src/utils/UserInitializer.tsx
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
"use client";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import useUserStore from "@/stores/userStore";
|
||||||
|
|
||||||
|
const UserInitializer = () => {
|
||||||
|
const initialize = useUserStore((state) => state.initialize);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
initialize();
|
||||||
|
}, [initialize]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default UserInitializer;
|
||||||
29
src/utils/WithTooltip.tsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/UI/Tooltip";
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
type WithTooltipProps = {
|
||||||
|
children: React.ReactNode;
|
||||||
|
delayDuration?: number;
|
||||||
|
tooltipContent: string;
|
||||||
|
side: "top" | "right" | "bottom" | "left";
|
||||||
|
sideOffset?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function WithTooltip({
|
||||||
|
children,
|
||||||
|
delayDuration = 400,
|
||||||
|
tooltipContent,
|
||||||
|
side,
|
||||||
|
sideOffset,
|
||||||
|
}: WithTooltipProps) {
|
||||||
|
return (
|
||||||
|
<Tooltip delayDuration={delayDuration}>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<div>{children}</div>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side={side} sideOffset={sideOffset}>
|
||||||
|
<p>{tooltipContent}</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
15
src/utils/apiRoutes.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
export const api = process.env.NEXT_PUBLIC_API_URL;
|
||||||
|
|
||||||
|
// auth
|
||||||
|
export const POST_OTP = api + "/api/sms/otp";
|
||||||
|
export const POST_VERIFY_OTP_LOGIN_AND_SIGNUP = api + "/api/login/otp";
|
||||||
|
export const POST_USER_PASS_LOGIN = api + "/api/login/user-pass";
|
||||||
|
export const POST_LOGOUT = api + "/api/logout";
|
||||||
|
// auth
|
||||||
|
|
||||||
|
// user panel
|
||||||
|
export const GET_USER_ROUTE = api + "/api/client";
|
||||||
|
// user panel
|
||||||
|
|
||||||
|
export const GET_PROVINCES = api + "/provinces";
|
||||||
|
export const GET_CITIES = api + "/api/cities";
|
||||||
63
src/utils/locateUser.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import { useLocationStore } from "@/stores/LocationStore";
|
||||||
|
import { LatLng } from "@/types/types";
|
||||||
|
import toast from "react-hot-toast";
|
||||||
|
|
||||||
|
export function getUserLocation(): Promise<LatLng> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (!navigator.geolocation) {
|
||||||
|
toast.error("مرورگر شما از موقعیتیابی پشتیبانی نمیکند");
|
||||||
|
reject(new Error("Geolocation is not supported by your browser."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let didRespond = false;
|
||||||
|
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
if (!didRespond) {
|
||||||
|
didRespond = true;
|
||||||
|
toast.error("دریافت موقعیت با مشکل مواجه شد");
|
||||||
|
reject(new Error("The request to get your location timed out."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}, 500); // TODO: refactor to handle more gracefully
|
||||||
|
|
||||||
|
navigator.geolocation.getCurrentPosition(
|
||||||
|
(position) => {
|
||||||
|
if (didRespond) return;
|
||||||
|
didRespond = true;
|
||||||
|
clearTimeout(timeout);
|
||||||
|
const { latitude, longitude } = position.coords;
|
||||||
|
useLocationStore.setState({ userLocation: { lat: latitude, lng: longitude } });
|
||||||
|
resolve({ lat: latitude, lng: longitude });
|
||||||
|
},
|
||||||
|
(error) => {
|
||||||
|
if (didRespond) return;
|
||||||
|
didRespond = true;
|
||||||
|
clearTimeout(timeout);
|
||||||
|
|
||||||
|
switch (error.code) {
|
||||||
|
case error.PERMISSION_DENIED:
|
||||||
|
reject(new Error("Location access is denied. Please enable it in your browser settings."));
|
||||||
|
toast.error("اجازه دسترسی به موقعیت رد شده است");
|
||||||
|
break;
|
||||||
|
case error.POSITION_UNAVAILABLE:
|
||||||
|
reject(new Error("Location information is unavailable."));
|
||||||
|
toast.error("موقعیت در دسترس نیست");
|
||||||
|
break;
|
||||||
|
case error.TIMEOUT:
|
||||||
|
reject(new Error("The request to get your location timed out."));
|
||||||
|
toast.error("مدت زمان برای دریافت موقعیت منقضی شد");
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
toast.error("خطای ناشناخته");
|
||||||
|
reject(new Error("An unknown error occurred while getting location."));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
enableHighAccuracy: true,
|
||||||
|
maximumAge: 0,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
58
src/utils/useMapMove.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import { LatLng } from "@/types/types";
|
||||||
|
import { MapRef, useMap } from "@vis.gl/react-maplibre";
|
||||||
|
import maplibregl, { LngLatLike } from "maplibre-gl";
|
||||||
|
|
||||||
|
export function useMapMove() {
|
||||||
|
const { homePageMap: map } = useMap();
|
||||||
|
|
||||||
|
function mapMove(evt: any) {
|
||||||
|
const view = evt.target.getCenter();
|
||||||
|
const zoom = evt.target.getZoom();
|
||||||
|
|
||||||
|
const viewState = {
|
||||||
|
longitude: view.lng,
|
||||||
|
latitude: view.lat,
|
||||||
|
zoom,
|
||||||
|
};
|
||||||
|
|
||||||
|
localStorage.setItem("mapview", JSON.stringify(viewState));
|
||||||
|
}
|
||||||
|
|
||||||
|
function flyToBounds(coordinates: LngLatLike[], mapRef: MapRef | undefined = map, duration = 1000) {
|
||||||
|
if (!mapRef) return;
|
||||||
|
|
||||||
|
const bounds = new maplibregl.LngLatBounds();
|
||||||
|
coordinates.forEach((c) => bounds.extend(c));
|
||||||
|
|
||||||
|
const vw = window.innerWidth;
|
||||||
|
const vh = window.innerHeight;
|
||||||
|
|
||||||
|
mapRef.fitBounds(bounds, {
|
||||||
|
padding: {
|
||||||
|
top: vh / 5,
|
||||||
|
right: vw / 3,
|
||||||
|
bottom: vh / 3,
|
||||||
|
left: vw / 5,
|
||||||
|
},
|
||||||
|
duration,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const flyToLocation = (location: LatLng, zoom: number = 15, mapRef: MapRef | undefined = map) => {
|
||||||
|
if (mapRef) {
|
||||||
|
mapRef.getMap().flyTo({
|
||||||
|
center: [location.lng, location.lat],
|
||||||
|
zoom: zoom,
|
||||||
|
speed: 1.2,
|
||||||
|
curve: 1.42,
|
||||||
|
essential: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
mapMove,
|
||||||
|
flyToBounds,
|
||||||
|
flyToLocation,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -22,13 +22,6 @@
|
|||||||
"@/*": ["./src/*"]
|
"@/*": ["./src/*"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"include": [
|
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", ".next/dev/types/**/*.ts", "**/*.mts"],
|
||||||
"next-env.d.ts",
|
|
||||||
"**/*.ts",
|
|
||||||
"**/*.tsx",
|
|
||||||
".next/types/**/*.ts",
|
|
||||||
".next/dev/types/**/*.ts",
|
|
||||||
"**/*.mts"
|
|
||||||
],
|
|
||||||
"exclude": ["node_modules"]
|
"exclude": ["node_modules"]
|
||||||
}
|
}
|
||||||
|
|||||||