Compare commits

...

10 Commits

Author SHA1 Message Date
Amin Ghasempoor
2dbaf6790e Merge pull request #3 from aminghasempoor/feature/import_colors
Feature/import colors
2026-02-05 09:05:09 +03:30
f810762013 add 512x logo 2026-02-05 08:59:53 +03:30
ae697df0b2 add colors and error components 2026-02-03 10:57:11 +03:30
Amin Ghasempoor
e0eef542de Merge pull request #2 from aminghasempoor/feature/install_packages
add pwa
2026-02-03 10:00:25 +03:30
428cba0616 add pwa 2026-02-03 09:58:28 +03:30
Amin Ghasempoor
a83b6b3fe4 Merge pull request #1 from aminghasempoor/feature/install_packages
Feature/install packages
2025-12-24 12:20:51 +03:30
87451f3d19 add pwa 2025-12-24 12:04:45 +03:30
6a644b9a4e theme provider 2025-12-24 11:49:17 +03:30
c5872fb762 install packages 2025-12-24 11:20:50 +03:30
87b5f80f1f create project 2025-12-24 09:46:55 +03:30
125 changed files with 25569 additions and 4271 deletions

10
.idea/.gitignore generated vendored Normal file
View File

@@ -0,0 +1,10 @@
# Default ignored files
/shelf/
/workspace.xml
# Ignored default folder with query files
/queries/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml
# Editor-based HTTP Client requests
/httpRequests/

View File

@@ -0,0 +1,6 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="Eslint" enabled="true" level="WARNING" enabled_by_default="true" />
</profile>
</component>

8
.idea/modules.xml generated Normal file
View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/salonro.iml" filepath="$PROJECT_DIR$/.idea/salonro.iml" />
</modules>
</component>
</project>

8
.idea/salonro.iml generated Normal file
View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="WEB_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$" />
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

6
.idea/vcs.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>

13
.prettierignore Normal file
View 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
View 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
View 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"]

View File

@@ -1,36 +1,36 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.

16
docker-compose.yml Normal file
View 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 نشود

View File

@@ -1,18 +1,23 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
{
rules: {
"@typescript-eslint/no-explicit-any": "off",
},
},
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;

360
messages/fa.json Normal file
View 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": "انصراف"
}
}

View File

@@ -1,7 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
};
export default nextConfig;
import type { NextConfig } from "next";
import createNextIntlPlugin from "next-intl/plugin";
const withNextIntl = createNextIntlPlugin();
const nextConfig: NextConfig = {};
export default withNextIntl(nextConfig);

View File

@@ -1,26 +1,60 @@
{
"name": "salonro",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint"
},
"dependencies": {
"next": "16.1.1",
"react": "19.2.3",
"react-dom": "19.2.3"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.1.1",
"tailwindcss": "^4",
"typescript": "^5"
}
}
{
"name": "salonro",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint",
"format": "npx prettier . --write"
},
"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",
"js-cookie": "^3.0.5",
"maplibre-gl": "^5.4.0",
"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-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": {
"@tailwindcss/postcss": "^4",
"@types/js-cookie": "^3.0.6",
"@types/next-pwa": "^5.6.9",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.1.1",
"prettier": "3.5.3",
"prettier-plugin-tailwindcss": "^0.6.14",
"tailwindcss": "^4",
"typescript": "^5"
},
"packageManager": "pnpm@10.10.0+sha512.d615db246fe70f25dcfea6d8d73dee782ce23e2245e3c4f6f888249fb568149318637dca73c2c5c8ef2a4ca0d5657fb9567188bfab47f566d1ee6ce987815c39"
}

16016
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,5 @@
packages:
- .
ignoredBuiltDependencies:
- sharp
- unrs-resolver
packages:
- .
ignoredBuiltDependencies:
- sharp
- unrs-resolver

View File

@@ -1,7 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;

View File

@@ -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
View 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;
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View 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

View File

@@ -0,0 +1,4 @@
<svg width="16" height="18" viewBox="0 0 16 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M2.00018 6.00546C2.02942 2.63953 4.71223 -0.0181599 8.0566 9.34618e-05C11.3571 0.0183468 14.0326 2.7454 13.9997 6.05657C13.9668 9.35678 11.2657 12.0218 7.97253 11.9999C4.66105 11.9743 1.9746 9.28011 2.00018 6.00546ZM8.00542 2.01162C5.75757 2.02257 3.98487 3.8114 3.99949 6.04196C4.01411 8.25792 5.80874 10.0175 8.03832 10.0066C10.246 9.99564 12.0187 8.18856 12.0004 5.96165C11.9821 3.77489 10.1838 1.99701 8.00177 2.00797L8.00542 2.01162Z" fill="currentColor"/>
<path d="M8.00818 14.0035C9.07475 14.0728 10.1486 14.0797 11.2043 14.2252C12.9006 14.4643 14.3712 15.2057 15.6416 16.304C16.0821 16.6851 16.1185 17.2706 15.7399 17.669C15.3723 18.0571 14.7716 18.0882 14.3275 17.7106C13.2719 16.8063 12.0524 16.2347 10.651 16.0233C10.4507 15.9921 10.2469 15.9575 10.043 15.9575C8.60517 15.961 7.16002 15.9194 5.72579 15.9991C4.25515 16.0788 2.97745 16.6816 1.85627 17.5893C1.6415 17.7626 1.37577 17.9219 1.11003 17.9808C0.691413 18.0744 0.269152 17.8145 0.0907827 17.4404C-0.0875863 17.0662 -0.000222027 16.6331 0.327394 16.3455C1.52137 15.3027 2.90464 14.6132 4.48812 14.2668C5.64934 14.0139 6.82876 14.0693 8.00818 14V14.0035Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -0,0 +1,3 @@
<svg width="12" height="10" viewBox="0 0 12 10" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4 9.4L0 5.4L1.4 4L4 6.6L10.6 0L12 1.4L4 9.4Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 185 B

View File

@@ -0,0 +1,3 @@
<svg width="8" height="12" viewBox="0 0 8 12" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6 12L0 6L6 0L7.4 1.4L2.8 6L7.4 10.6L6 12Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 176 B

View File

@@ -0,0 +1,8 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<mask id="mask0_134_1984" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="16" height="16">
<rect width="16" height="16" fill="currentColor"/>
</mask>
<g mask="url(#mask0_134_1984)">
<path d="M3.17605 15.3291L1.30005 13.4624L6.12405 8.62907L1.30005 3.82907L3.17605 1.9624L8.00005 6.78274L12.7907 1.9624L14.6667 3.82907L9.84272 8.62907L14.6667 13.4624L12.7907 15.3291L8.00005 10.5087L3.17605 15.3291Z" fill="currentColor"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 584 B

View File

@@ -0,0 +1,3 @@
<svg width="25" height="24" viewBox="0 0 25 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6.31233 4.70137L4.33973 2.73973L2.79452 4.28493L4.75616 6.24657L6.31233 4.70137ZM0 10.9041H3.28767V13.0959H0V10.9041ZM10.9589 0H13.1507V3.23288H10.9589V0ZM19.7699 2.73425L21.3129 4.27616L19.3512 6.23781L17.8093 4.69479L19.7699 2.73425ZM17.7973 19.2986L19.7589 21.2712L21.3041 19.726L19.3315 17.7644L17.7973 19.2986ZM20.8219 10.9041H24.1096V13.0959H20.8219V10.9041ZM12.0548 5.42466C8.4274 5.42466 5.47945 8.3726 5.47945 12C5.47945 15.6274 8.4274 18.5753 12.0548 18.5753C15.6822 18.5753 18.6301 15.6274 18.6301 12C18.6301 8.3726 15.6822 5.42466 12.0548 5.42466ZM12.0548 16.3836C9.63288 16.3836 7.67123 14.4219 7.67123 12C7.67123 9.57808 9.63288 7.61644 12.0548 7.61644C14.4767 7.61644 16.4384 9.57808 16.4384 12C16.4384 14.4219 14.4767 16.3836 12.0548 16.3836ZM10.9589 20.7671H13.1507V24H10.9589V20.7671ZM2.79452 19.7151L4.33973 21.2603L6.30137 19.2877L4.75616 17.7425L2.79452 19.7151Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 1020 B

View File

@@ -0,0 +1,40 @@
<?xml version="1.0" encoding="iso-8859-1"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg height="800px" width="800px" version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
viewBox="0 0 512 512" xml:space="preserve">
<circle style="fill:#40A459;" cx="255.722" cy="256" r="255.445"/>
<path style="fill:#378B4E;" d="M255.722,0.555c-1.944,0-3.878,0.03-5.812,0.073c-0.492,0.011-0.983,0.022-1.474,0.037
c-1.843,0.051-3.682,0.119-5.514,0.209c-0.474,0.023-0.945,0.056-1.418,0.081c-1.394,0.077-2.785,0.165-4.174,0.264
c-0.699,0.05-1.397,0.098-2.094,0.153c-1.61,0.128-3.217,0.27-4.82,0.428c-0.829,0.082-1.654,0.173-2.479,0.262
c-0.991,0.108-1.98,0.221-2.968,0.34c-0.856,0.103-1.714,0.202-2.567,0.313c125.334,16.327,222.126,123.498,222.126,253.282
S347.737,492.953,222.403,509.28c0.854,0.111,1.71,0.211,2.567,0.313c0.987,0.119,1.977,0.232,2.968,0.34
c0.826,0.09,1.652,0.181,2.479,0.262c1.603,0.158,3.209,0.3,4.82,0.428c0.696,0.056,1.395,0.104,2.094,0.153
c1.388,0.099,2.779,0.188,4.174,0.264c0.473,0.027,0.945,0.058,1.418,0.081c1.833,0.09,3.672,0.158,5.514,0.209
c0.491,0.014,0.982,0.026,1.474,0.037c1.932,0.043,3.868,0.073,5.812,0.073c141.079,0,255.445-114.367,255.445-255.445
S396.801,0.555,255.722,0.555z"/>
<path style="fill:#898790;" d="M222.403,233.787l106.563-61.152c0,0,116.569,24.829,143.473-24.263l8.485-15.483l7.379,16.04
c15.321,33.304,23.42,70.329,23.42,107.071c0,84.691-41.95,163.833-112.217,211.703l-7.25,4.94L222.403,233.787z"/>
<path style="fill:#7A797F;" d="M511.166,256c0-44.143-11.198-85.671-30.908-121.898l-7.82,14.269
c-9.45,17.245-21.888,34.878-34.284,50.741c4.162,18.295,6.374,37.333,6.374,56.888c0,71.337-29.248,135.834-76.4,182.176
l23.852,34.666C463.639,427.598,511.166,347.012,511.166,256z"/>
<path style="fill:#3D9AE3;" d="M255.722,512c-87.455,0-168.01-44.081-215.484-117.917l-4.527-7.04l142.267-119.937l163.74,230.122
l-10.434,3.385C306.874,508.169,281.452,512,255.722,512z"/>
<path style="fill:#1D81CE;" d="M342.752,496.827l-17.317-24.795c-30.569,19.333-65.625,32.915-103.137,37.801
c10.906,1.42,22.13,1.612,33.424,1.612C286.17,511.445,315.674,506.6,342.752,496.827z"/>
<path style="fill:#FFFFFF;" d="M177.978,267.106l44.425-33.319l170.805,238.278l-8.302,4.872
c-11.729,6.884-24.073,12.861-36.69,17.766l-6.499,2.527L177.978,267.106z"/>
<path style="fill:#E0E0E3;" d="M341.718,497.229c18.2-6.475,35.457-14.944,51.49-25.164l-24.615-34.338
c-13.395,13.232-28.248,24.992-44.293,35.023L341.718,497.229z"/>
<path style="fill:#FFCE00;" d="M31.37,379.188c-7.01-12.718-12.964-26.113-17.696-39.813l-2.112-8.059l299.691-208.592
l44.425,33.319L35.712,387.042L31.37,379.188z"/>
<path style="fill:#CD2900;" d="M382.579,250.216c-15.713-16.41-94.094-100.753-94.094-149.704C288.485,45.089,333.575,0,388.997,0
s100.512,45.089,100.512,100.512c0,48.918-78.382,133.287-94.095,149.704l0,0C391.917,253.87,386.077,253.871,382.579,250.216
L382.579,250.216z"/>
<path style="fill:#891D00;" d="M388.997,134.386c-24.803,0-44.98-20.178-44.98-44.98s20.178-44.98,44.98-44.98
c24.803,0,44.98,20.178,44.98,44.98S413.8,134.386,388.997,134.386z"/>
<path style="fill:#FFFFFF;" d="M144.659,186.586c29.088,0,52.755-23.666,52.755-52.755c0-4.6-3.729-8.33-8.33-8.33h-33.319
c-4.6,0-8.33,3.729-8.33,8.33c0,4.6,3.729,8.33,8.33,8.33h24.018c-3.769,15.901-18.088,27.766-35.125,27.766
c-19.902,0-36.095-16.193-36.095-36.095s16.193-36.095,36.095-36.095c8.8,0,17.275,3.202,23.865,9.015
c3.452,3.044,8.715,2.713,11.757-0.736c3.043-3.45,2.713-8.714-0.736-11.757c-9.636-8.5-22.025-13.181-34.886-13.181
c-29.088,0-52.755,23.666-52.755,52.755S115.57,186.586,144.659,186.586z"/>
</svg>

After

Width:  |  Height:  |  Size: 3.6 KiB

View File

@@ -0,0 +1,3 @@
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6 3C6 7.97056 10.0294 12 15 12C15.9093 12 16.787 11.8655 17.6144 11.6147C16.4943 15.3103 13.0613 17.9999 9 17.9999C4.02944 17.9999 0 13.9707 0 9.00014C0 4.93883 2.69007 1.50583 6.38561 0.385742C6.13484 1.21311 6 2.09074 6 3Z" fill="#273343"/>
</svg>

After

Width:  |  Height:  |  Size: 356 B

4
public/icons/logout.svg Normal file
View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M14 4L18 4C19.1046 4 20 4.89543 20 6V18C20 19.1046 19.1046 20 18 20H14M3 12L15 12M3 12L7 8M3 12L7 16" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 348 B

BIN
public/logo/144px.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

BIN
public/logo/152px.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

BIN
public/logo/192px.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

BIN
public/logo/384px.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

BIN
public/logo/512px.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

View File

@@ -0,0 +1,40 @@
{
"id": "salonro-app",
"name": "سالنرو",
"short_name": "سالنرو",
"description": "مدیریت رزرواسیون",
"start_url": "/",
"scope": "/",
"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",
"purpose": "any maskable"
}
]
}

9388
public/mapbox-gl-rtl-text.js Normal file

File diff suppressed because one or more lines are too long

View 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

12
public/offline.html Normal file
View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="fa" dir="rtl">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>آفلاین</title>
</head>
<body style="font-family: sans-serif; text-align: center; padding-top: 50px">
<h1>اتصال اینترنت قطع است</h1>
<p>لطفاً اتصال خود را بررسی کنید.</p>
</body>
</html>

24
public/sw.js Normal file
View File

@@ -0,0 +1,24 @@
const CACHE_NAME = "salonro-cache-v2";
const STATIC_ASSETS = ["/", "/manifest.webmanifest", "/logo/192px.png", "/logo/512px.png"];
self.addEventListener("install", (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then(async (cache) => {
await Promise.allSettled(
STATIC_ASSETS.map(async (url) => {
try {
const response = await fetch(url);
if (response.ok) {
await cache.put(url, response);
}
} catch (e) {
console.warn("❌ Failed to cache:", url);
}
})
);
})
);
self.skipWaiting();
});

View File

@@ -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

View File

@@ -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

View File

@@ -0,0 +1,3 @@
export default async function RootLayout({ children }: { children: React.ReactNode }) {
return <div className={"bg-bg"}>{children}</div>;
}

View File

@@ -0,0 +1,11 @@
"use client";
import { useDeviceStore } from "@/stores/useDeviceStore";
import dynamic from "next/dynamic";
const HomeMobile = dynamic(() => import("@/components/Home/Mobile"), { ssr: false });
const HomeDesktop = dynamic(() => import("@/components/Home/Desktop"), { ssr: false });
export default function HomePage() {
const isMobile = useDeviceStore((state) => state.isMobile);
return isMobile ? <HomeMobile /> : <HomeDesktop />;
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 4.5 KiB

42
src/app/global-error.tsx Normal file
View File

@@ -0,0 +1,42 @@
"use client";
export default function GlobalError() {
return (
<html>
<body className="bg-bg text-text-primary flex min-h-screen items-center justify-center">
<div className="bg-card mx-4 w-full max-w-md rounded-xl p-8 text-center shadow-lg">
{/* آیکون هشدار */}
<div className="mb-4 flex justify-center">
<svg
xmlns="http://www.w3.org/2000/svg"
className="text-warning h-16 w-16"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</div>
{/* متن خطا */}
<h2 className="mb-3 text-2xl font-bold">مشکلی در سامانه رخ داده است</h2>
<p className="text-text-secondary mb-8 leading-relaxed">لطفاً ساعاتی بعد مجدد تلاش کنید.</p>
{/* دکمه رفرش */}
<button
onClick={() => window.location.reload()}
className="bg-primary-100 hover:bg-primary-200 focus:ring-primary-300 inline-block rounded-lg px-6 py-3 font-medium text-white shadow-md transition-all duration-200 hover:shadow-lg focus:ring-2 focus:ring-offset-2 focus:outline-none"
>
تلاش مجدد
</button>
</div>
</body>
</html>
);
}

View File

@@ -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;
}

View File

@@ -1,34 +1,70 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
{children}
</body>
</html>
);
}
import type { Metadata } from "next";
import "&/fonts.css";
import "@/styles/globals.css";
import { NextIntlClientProvider } from "next-intl";
import { getLocale, getMessages } from "next-intl/server";
import NextTopLoader from "nextjs-toploader";
import { ClientAppProvider } from "@/providers/ClientAppProvider";
import UserInitializer from "@/utils/UserInitializer";
import Modal from "@/components/UI/Modal";
import ToastProvider from "@/utils/ToastProvider";
import { Sidebar } from "@/components/SideBar";
import { AppShell } from "@/providers/AppShell";
import ServiceWorkerRegister from "@/components/ServiceWorkerRegister";
export const metadata: Metadata = {
title: {
template: "%s | سالن رو",
default: "سالن رو",
},
description: "وب سایت سالن رو",
applicationName: "سالنرو",
authors: {
name: "شرکت میرفران تک",
url: "https://menulita.ir",
},
manifest: "/manifest.webmanifest",
icons: {
icon: "/logo/192px.png",
apple: "/logo/512px.png",
},
appleWebApp: {
capable: true,
statusBarStyle: "default",
title: "سالنرو",
},
};
export const viewport = {
width: "device-width",
initialScale: 1,
maximumScale: 1,
};
export default async function RootLayout({ children }: { children: React.ReactNode }) {
const locale = await getLocale();
const messages = await getMessages({ locale });
return (
<html lang={locale} dir="rtl">
<body>
<ServiceWorkerRegister />
<NextTopLoader color="#16a795" />
<NextIntlClientProvider locale={locale} messages={messages}>
<ClientAppProvider>
<AppShell>
<UserInitializer />
{children}
<Modal />
<ToastProvider />
<Sidebar />
</AppShell>
</ClientAppProvider>
</NextIntlClientProvider>
</body>
</html>
);
}

22
src/app/not-found.tsx Normal file
View File

@@ -0,0 +1,22 @@
import Link from "next/link";
export default function NotFound() {
return (
<div className="bg-bg text-text-primary flex min-h-screen items-center justify-center">
<div className="bg-card mx-4 w-full max-w-md rounded-xl p-8 text-center shadow-lg">
<h2 className="mb-2 text-2xl font-bold">صفحه مورد نظر یافت نشد</h2>
<p className="text-text-secondary mb-6 leading-relaxed">
متأسفانه صفحهای که به دنبال آن هستید وجود ندارد.
</p>
<Link
href="/"
className="bg-primary-100 hover:bg-primary-200 inline-block rounded-lg px-6 py-3 font-medium text-white shadow-md transition-all duration-200 hover:shadow-lg"
>
بازگشت به صفحه اصلی
</Link>
</div>
</div>
);
}

View File

@@ -1,65 +0,0 @@
import Image from "next/image";
export default function Home() {
return (
<div className="flex min-h-screen items-center justify-center bg-zinc-50 font-sans dark:bg-black">
<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>
);
}

8
src/assets/index.ts Normal file
View File

@@ -0,0 +1,8 @@
export { default as CheckIcon } from "&/icons/check-icon.svg";
export { default as ChevronIcon } from "&/icons/chevron-icon.svg";
export { default as GoogleMapsIcon } from "&/icons/google-maps.svg";
export { default as CloseIcon } from "&/icons/close-icon.svg";
export { default as LogoutIcon } from "&/icons/logout.svg";
export { default as AvatarIcon } from "&/icons/avatar-icon.svg";
export { default as DarkModeIcon } from "&/icons/dark-mode-icon.svg";
export { default as LightModeIcon } from "&/icons/light-mode-icon.svg";

View 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": "گیگابایت"
}

View File

@@ -0,0 +1,3 @@
export default function DesktopHeader() {
return <></>;
}

View File

@@ -0,0 +1,3 @@
export default function MobileHeader() {
return <></>;
}

View File

@@ -0,0 +1,3 @@
export default function DesktopHome() {
return <></>;
}

View File

@@ -0,0 +1,3 @@
export default function MobileHome() {
return <></>;
}

View File

@@ -0,0 +1,39 @@
import { useAuth } from "@/hooks/useAuth";
import { useModalStore } from "@/stores/useModalStore";
import { useTranslations } from "next-intl";
const LogoutModal = () => {
const t = useTranslations("logout");
const closeModal = useModalStore((s) => s.closeModal);
const { logout } = useAuth();
return (
<div className="bg-desktop-primary text-text flex flex-col gap-4 rounded-xl p-4">
<div className="space-y-1">
<h2 className="text-base font-semibold">{t("logout")}</h2>
<p className="text-text/75 max-w-3xs text-sm">{t("confirmLogout")}</p>
</div>
<div className="flex gap-2">
<button
onClick={closeModal}
className="border-lines/40 hover:bg-lines/10 flex-1 rounded-full border px-3 py-1 text-sm"
>
{t("cancel")}
</button>
<button
onClick={() => {
logout().then(() => {
closeModal();
});
}}
className="bg-error hover:bg-error/75 flex-1 rounded-full px-3 py-1 text-sm text-white"
>
{t("logout")}
</button>
</div>
</div>
);
};
export default LogoutModal;

View File

@@ -0,0 +1,104 @@
import neshanLogo from "&/icons/neshan-logo.png";
import logo from "&/logo/144px.png";
import { CloseIcon, GoogleMapsIcon } from "@/assets";
import { useLocationStore } from "@/stores/LocationStore";
import { useModalStore } from "@/stores/useModalStore";
import Image from "next/image";
import { JSX } from "react";
type MapLauncherItem = {
id: string;
icon: JSX.Element;
label: string;
onClick: () => void;
};
export default function MapLauncherModal() {
const destination = useLocationStore((s) => s.destination);
const origin = useLocationStore((s) => s.origin);
const closeModal = useModalStore((s) => s.closeModal);
const openModal = useModalStore((s) => s.openModal);
function openGoogleMaps() {
if (!destination) return;
const originParam = origin ? `&origin=${origin.lat},${origin.lng}` : "";
const url = `https://www.google.com/maps/dir/?api=1${originParam}&destination=${destination.lat},${destination.lng}`;
window.location.href = url;
}
function open141Map() {
openModal(
<div>
<div className="bg-desktop-primary text-text flex flex-col gap-4 rounded-xl p-4">
<div className="space-y-1">
<p className="text-text/75 max-w-3xs text-sm">
به زودی میتوانید از اپلیکیشن 141 برای مسیریابی استفاده کنید.
</p>
</div>
<div className="flex gap-2">
<button
onClick={closeModal}
className="border-lines/40 hover:bg-lines/10 flex-1 rounded-full border px-3 py-1 text-sm"
>
متوجه شدم
</button>
</div>
</div>
</div>
);
}
function openNeshanMap() {
if (!destination) return;
const url = `https://nshn.ir/?lat=${destination.lat}&lng=${destination.lng}`;
window.location.href = url;
}
const launchers: MapLauncherItem[] = [
{
id: "google-maps",
icon: <GoogleMapsIcon className="size-10" />,
label: "Google Maps",
onClick: openGoogleMaps,
},
{
id: "141",
icon: <Image src={logo} alt="141 Map" className="size-10" />,
label: "141",
onClick: open141Map,
},
{
id: "neshan",
icon: <Image src={neshanLogo} alt="neshan Map" className="size-10" />,
label: "نشان",
onClick: openNeshanMap,
},
];
return (
<div className="bg-pwa-primary text-text w-full max-w-100 min-w-81 rounded-2xl">
<div className="border-lines/20 flex items-center justify-between border-b px-4 py-3">
<button onClick={closeModal} aria-label="Close">
<CloseIcon className="text-text/80 size-5" />
</button>
</div>
<div className="grid grid-cols-3 gap-3 p-4 py-6">
{launchers.map((item) => (
<button
key={item.id}
onClick={item.onClick}
className="group border-lines/20 bg-buttons/50 flex flex-col items-center justify-center gap-2 rounded-xl border p-3 shadow-sm transition hover:shadow-md active:scale-[0.97]"
>
<div className="flex items-center justify-center">{item.icon}</div>
<span className="text-text/80 text-xs font-medium">{item.label}</span>
</button>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,36 @@
import { useModalStore } from "@/stores/useModalStore";
import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation";
export default function RedirectToSignInModal({ confirmLabel = "ورود" }) {
const t = useTranslations("Bookmarks");
const closeModal = useModalStore((s) => s.closeModal);
const router = useRouter();
return (
<div className="bg-desktop-primary text-text flex flex-col gap-4 rounded-xl p-4">
<div className="space-y-1">
<h2 className="text-base font-semibold">{t("signInModalTitle")}</h2>
<p className="text-text/75 max-w-3xs text-sm">{t("signInModalDescription")}</p>
</div>
<div className="flex gap-2">
<button
onClick={closeModal}
className="border-lines/40 hover:bg-lines/10 flex-1 rounded-full border px-3 py-1 text-sm"
>
{t("abort")}
</button>
<button
onClick={() => {
closeModal();
router.push("/auth");
}}
className="bg-neo-aqua hover:bg-neo-aqua/75 flex-1 rounded-full px-3 py-1 text-sm text-white"
>
{t("signin")}
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,20 @@
"use client";
import { useEffect } from "react";
export default function ServiceWorkerRegister() {
useEffect(() => {
if ("serviceWorker" in navigator) {
navigator.serviceWorker
.register("/sw.js")
.then((registration) => {
console.log("✅ SW registered:", registration.scope);
})
.catch((error) => {
console.error("❌ SW registration failed:", error);
});
}
}, []);
return null;
}

View File

@@ -0,0 +1,33 @@
import { ReactNode } from "react";
import Link from "next/link";
import { useSidebarStore } from "@/stores/SidebarStore";
interface MenuItemProps {
label: string;
onClick?: () => void;
icon?: ReactNode;
href?: string;
}
export default function MenuItem({ label, onClick, icon, href }: MenuItemProps) {
const close = useSidebarStore((state) => state.close);
function handleClick() {
onClick?.();
close();
}
return (
<Link
href={href || "#"}
prefetch={href !== "/complaints"}
onClick={handleClick}
className="hover:bg-desktop-primary/25 flex w-full cursor-pointer items-center justify-between rounded-md px-2 py-2 text-sm font-bold"
>
<span className="flex items-center gap-2">
{icon}
<p className="text-text line-clamp-1">{label}</p>
</span>
</Link>
);
}

View File

@@ -0,0 +1,42 @@
import { ChevronIcon } from "@/assets";
import { AnimatePresence, motion } from "framer-motion";
import { ReactNode, useState } from "react";
interface SubMenuProps {
label: string;
children: ReactNode;
}
export default function SubMenu({ label, children }: SubMenuProps) {
const [isOpen, setIsOpen] = useState(false);
return (
<div className="w-full">
<button
onClick={() => setIsOpen((prev) => !prev)}
className="flex w-full cursor-pointer items-center justify-between rounded-md px-2 py-2 text-sm font-bold"
>
<span className="text-text line-clamp-1">{label}</span>
<motion.div
animate={{ rotate: isOpen ? 180 : 0 }}
transition={{ duration: 0.2 }}
className="bg-lines/25 rounded-lg p-1"
>
<ChevronIcon className="text-text size-4 rotate-270 opacity-50" />
</motion.div>
</button>
<AnimatePresence initial={false}>
{isOpen && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
className="pr-3 pl-6"
>
{children}
</motion.div>
)}
</AnimatePresence>
</div>
);
}

View File

@@ -0,0 +1,51 @@
import SubMenu from "./SubMenu";
import MenuItem from "./MenuItem";
import Separator from "@/components/UI/Separator";
import { motion, AnimatePresence } from "framer-motion";
type Menu = {
title: string;
href?: string;
children?: Menu[];
};
interface MenuRendererProps {
items: Menu[];
level?: number;
}
export function SideBarComponent({ items, level = 0 }: MenuRendererProps) {
return (
<div className="z-50 mt-2 flex flex-col gap-1">
{items.map((item, index) => (
<motion.div
key={item.title + index}
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: index * 0.05 }}
className="mb-3"
>
{item.children ? (
<>
<AnimatePresence initial={false}>
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
className="overflow-hidden"
>
<SubMenu label={item.title}>
<SideBarComponent items={item.children} level={level + 1} />
</SubMenu>
</motion.div>
</AnimatePresence>
</>
) : (
<MenuItem href={item.href} label={item.title} />
)}
{level === 0 && <Separator className="mt-2" />}
</motion.div>
))}
</div>
);
}

View File

@@ -0,0 +1,131 @@
"use client";
import { AvatarIcon, CloseIcon, LogoutIcon } from "@/assets";
import { SideBarComponent } from "./SideBarContent";
import ThemeToggle from "@/components/ThemeToggle";
import { mobileMenuItems } from "@/data/sidebarMenu";
import { useSidebarStore } from "@/stores/SidebarStore";
import useUserStore from "@/stores/userStore";
import { AnimatePresence, motion } from "framer-motion";
import { useTranslations } from "next-intl";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useEffect } from "react";
import { useModalStore } from "@/stores/useModalStore";
import LogoutModal from "@/components/Modals/LogoutModal";
export const Sidebar = () => {
const t = useTranslations("Sidebar");
const ta = useTranslations("Auth");
const isOpen = useSidebarStore((s) => s.isOpen);
const isAuth = useUserStore((s) => s.isAuth);
const close = useSidebarStore((state) => state.close);
const openModal = useModalStore((s) => s.openModal);
const pathName = usePathname();
function handleClick() {
close();
}
useEffect(() => {
return () => close();
}, []);
return (
<AnimatePresence>
{isOpen && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="bg-desktop-primary/5 fixed inset-0 z-50 backdrop-blur-xl"
onClick={close}
>
<div className="absolute inset-0 flex items-center justify-center p-4">
<motion.aside
initial={{ x: "100%" }}
animate={{ x: 0 }}
exit={{ x: "100%" }}
transition={{ type: "spring", stiffness: 300, damping: 30 }}
onClick={(e) => e.stopPropagation()}
className="bg-desktop-primary text-text flex h-full w-full max-w-md flex-col rounded-2xl p-4 shadow-lg"
>
{/* Header */}
<motion.div
key="close"
initial={{ opacity: 0, rotate: -45 }}
animate={{ opacity: 1, rotate: 0 }}
exit={{ opacity: 0, rotate: 45 }}
transition={{ duration: 0.1 }}
className={"mb-4 flex justify-between px-2"}
>
<button onClick={close}>
<CloseIcon className="text-text size-6" />
</button>
<ThemeToggle />
</motion.div>
{/* Nav Items */}
<nav className="overflow-y-auto">
{isAuth ? (
<div className="flex w-full flex-col justify-center gap-2 py-6">
<Link
onClick={handleClick}
href="/panel/profile"
className="bg-neo-aqua flex flex-row items-center justify-center gap-2 rounded-lg px-2 py-1 transition-all duration-200 hover:scale-101 active:scale-99"
>
<AvatarIcon className="size-4 text-white" />
<div className="h-4 w-[1.5px] bg-white" />
<p className="text-white">{ta("profile")}</p>
</Link>
<button
onClick={() => {
openModal(<LogoutModal />);
}}
className="bg-error flex flex-row items-center justify-center gap-2 rounded-lg px-2 py-1 transition-all duration-200 hover:scale-101 active:scale-99"
>
<LogoutIcon className="size-5 rotate-180 text-white" />
<p className="text-white">{ta("logout")}</p>
</button>
</div>
) : (
<div className="flex items-center gap-1 py-6">
<button className="bg-neo-aqua rounded-lg p-2">
<AvatarIcon className="size-4 text-white" />
</button>
<div className="bg-text hover:text-pwa-primary rounded-lg px-3 py-1 transition-all duration-300 hover:scale-101 active:scale-99">
<Link
href={`/auth?back_url=${pathName}`}
className="text-desktop-primary line-clamp-1 flex cursor-pointer flex-row items-center gap-2"
>
<p>{t("login")}</p>
<div className="bg-desktop-primary h-4 w-[1.5px]" />
<p>{t("register")}</p>
</Link>
</div>
</div>
)}
<SideBarComponent items={mobileMenuItems} />
</nav>
{/* Footer */}
<div className="text-text mt-auto pt-4 text-center text-[8px]">
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, ease: "easeOut" }}
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
className="bg-desktop-primary/10 mx-auto mb-3 flex w-fit items-center justify-center gap-4 rounded-2xl p-3 shadow-md"
></motion.div>
{t("policy")}
</div>
</motion.aside>
</div>
</motion.div>
)}
</AnimatePresence>
);
};

View File

@@ -0,0 +1,39 @@
import { DarkModeIcon, LightModeIcon } from "@/assets";
import { useMapLayersStore } from "@/stores/useMapLayersStore";
import { AnimatePresence, motion } from "framer-motion";
import { useTheme } from "next-themes";
export default function ThemeToggle() {
const { theme, setTheme } = useTheme();
const setActiveLayer = useMapLayersStore((s) => s.setActiveLayer);
function handleThemeToggle() {
if (theme === "dark") {
setActiveLayer("bright");
setTheme("light");
} else {
setActiveLayer("dark");
setTheme("dark");
}
}
return (
<AnimatePresence>
<button onClick={handleThemeToggle} className="flex cursor-pointer items-center py-2">
<motion.div
key={theme}
initial={{ rotate: -90, opacity: 0 }}
animate={{ rotate: 0, opacity: 1 }}
exit={{ rotate: 90, opacity: 0 }}
transition={{ duration: 0.5, ease: "easeInOut" }}
>
{theme === "dark" ? (
<DarkModeIcon className="text-text size-6" />
) : (
<LightModeIcon className="text-text size-6" />
)}
</motion.div>
</button>
</AnimatePresence>
);
}

View 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;

View 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}
/>
);
}

View 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;

View 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 };

View 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,
};

View 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)} />;
}

View 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 };

View 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 };

View File

@@ -0,0 +1,2 @@
// protected routes
export const protectedRoutes = ["/panel", "/complaints/complaints-panel", "/app-complaints"];

46
src/data/sidebarMenu.ts Normal file
View 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
View 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,
};
}

View 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
View 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
View 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
View 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
View 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
View 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
View 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,
}
);
};

View File

@@ -0,0 +1,13 @@
"use client";
import { AnimatePresence, motion } from "framer-motion";
export function AppShell({ children }: { children: React.ReactNode }) {
return (
<AnimatePresence mode="wait">
<motion.div key="app" initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ duration: 0.3 }}>
{children}
</motion.div>
</AnimatePresence>
);
}

View File

@@ -0,0 +1,23 @@
"use client";
import { ThemeProvider } from "next-themes";
import { useEffect, useEffectEvent, useState } from "react";
export function ClientAppProvider({ children }: { children: React.ReactNode }) {
const [mounted, setMounted] = useState(false);
const syncDevice = useEffectEvent(() => {
setMounted(true);
});
useEffect(() => {
syncDevice();
}, []);
if (!mounted) return null;
return (
<ThemeProvider attribute="data-theme" defaultTheme="dark" enableSystem>
{children}
</ThemeProvider>
);
}

View 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}</>;
}

Some files were not shown because too many files have changed in this diff Show More