Compare commits
10 Commits
dfe0f5ff5c
...
2dbaf6790e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2dbaf6790e | ||
| f810762013 | |||
| ae697df0b2 | |||
|
|
e0eef542de | ||
| 428cba0616 | |||
|
|
a83b6b3fe4 | ||
| 87451f3d19 | |||
| 6a644b9a4e | |||
| c5872fb762 | |||
| 87b5f80f1f |
10
.idea/.gitignore
generated
vendored
Normal 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/
|
||||
6
.idea/inspectionProfiles/Project_Default.xml
generated
Normal 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
@@ -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
@@ -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
@@ -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
@@ -0,0 +1,13 @@
|
||||
# Ignore build output
|
||||
dist/
|
||||
build/
|
||||
|
||||
# Ignore dependencies
|
||||
node_modules/
|
||||
|
||||
# Ignore lock files
|
||||
package-lock.json
|
||||
yarn.lock
|
||||
|
||||
coverage/
|
||||
*.min.js
|
||||
14
.prettierrc
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"plugins": ["prettier-plugin-tailwindcss"],
|
||||
|
||||
"printWidth": 120,
|
||||
"tabWidth": 4,
|
||||
"useTabs": false,
|
||||
"semi": true,
|
||||
"singleQuote": false,
|
||||
"trailingComma": "es5",
|
||||
"bracketSpacing": true,
|
||||
"jsxBracketSameLine": false,
|
||||
"arrowParens": "always",
|
||||
"endOfLine": "crlf"
|
||||
}
|
||||
50
Dockerfile
Normal file
@@ -0,0 +1,50 @@
|
||||
# ---------------------
|
||||
# Base (PNPM + Node)
|
||||
# ---------------------
|
||||
FROM docker.arvancloud.ir/node:22-alpine AS base
|
||||
RUN apk add --no-cache libc6-compat
|
||||
WORKDIR /app
|
||||
RUN npm install -g pnpm@10.8.0
|
||||
|
||||
# ---------------------
|
||||
# Dependencies
|
||||
# ---------------------
|
||||
FROM base AS deps
|
||||
WORKDIR /app
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
# ---------------------
|
||||
# Builder
|
||||
# ---------------------
|
||||
FROM base AS builder
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
ENV NEXT_PRIVATE_STANDALONE=true
|
||||
|
||||
RUN pnpm build
|
||||
|
||||
# ---------------------
|
||||
# Runner
|
||||
# ---------------------
|
||||
FROM docker.arvancloud.ir/node:22-alpine AS runner
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV PORT=3000
|
||||
|
||||
RUN addgroup --system --gid 1001 nodejs && \
|
||||
adduser --system --uid 1001 nextjs
|
||||
|
||||
COPY --from=builder /app/public ./public
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||
|
||||
USER nextjs
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["node", "server.js"]
|
||||
72
README.md
@@ -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
@@ -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 نشود
|
||||
@@ -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
@@ -0,0 +1,360 @@
|
||||
{
|
||||
"loading": "درحال دریافت داده",
|
||||
"error_fetch_data": "مشکلی در دریافت داده رخ داده است.",
|
||||
"Header": {},
|
||||
"HomePage": {
|
||||
"title": "سلام",
|
||||
"about141": "درباره 141",
|
||||
"blog": "وبلاگ",
|
||||
"searchThisArea": "جست و جو در این محدوده",
|
||||
"allRightsReserved": "تمامی حقوق این وبسایت متعلق به مرکز مدیریت راههای کشور میباشد."
|
||||
},
|
||||
"Application": {
|
||||
"title": "دانلود نرم افزار تلفن همراه 141",
|
||||
"description": "نسخهی تلفن همراه اپلیکیشن مرکز مدیریت راههای کشور با قابلیتهایی نظیر نمایش تصاویر دوربینهای، نظارتی در سراسر کشور، مسیریابی همراه با نمایش مسافت و زمان تقریبی و جزئیات مسیر، مشاهده اخبار متنی و شنیداری، کروکی ترافیک استانهای کشور، دریافت اطلاعیههای مربوط به مسیرهای پرتردد و ... آماده دریافت از مارکتهای مختلف میباشد.",
|
||||
"features": [
|
||||
{
|
||||
"step": "گام 1",
|
||||
"title": "سریعتر بسازید",
|
||||
"content": "MVP خود را در کمترین زمان ممکن با بلوکها و کامپوننتهای از پیش ساخته شده بسازید."
|
||||
},
|
||||
{
|
||||
"step": "گام 2",
|
||||
"title": "به راحتی شخصیسازی کنید",
|
||||
"content": "هر کامپوننت را با سیستم طراحی شهودی و معماری منعطف ما مطابق نیازتان تغییر دهید."
|
||||
},
|
||||
{
|
||||
"step": "گام 3",
|
||||
"title": "با اعتماد منتشر کنید",
|
||||
"content": "محصول خود را با اطمینان با استفاده از کامپوننتهای بهینه، واکنشگرا و دسترسپذیر منتشر کنید."
|
||||
},
|
||||
{
|
||||
"step": "گام 4",
|
||||
"title": "شما هم اضافه کنید!",
|
||||
"content": "بلوکهای خود را اضافه کنید و بخشی از جامعه MVPBlocks شوید."
|
||||
}
|
||||
]
|
||||
},
|
||||
"AboutUsPage": {
|
||||
"page_title": "شناسایی سریع وقایع جادهای، نقش اصلی مرکز مدیریت راههای کشور",
|
||||
"page_subtitle": "اطلاعات بیشتر درباره ۱۴۱",
|
||||
"history_title": "تاریخچه فعالیتها",
|
||||
"history_text": "مركز مديريت راههای کشور از آذرماه سال ۸۷ با هدف بهينهسازی بهرهبرداری از راههای كشور و كاهش تأخير در جريان ترافيك جادهای شكل گرفت. اين مركز كه سالها عنوان \"اطلاعات راهها\" را يدك ميكشيد، مسئوليت اطلاعرسانی از وضعيت راههای كشور را بر عهده داشت. از سال ۸۵ حركتی تازه در زمينه شكلگيری مركز مديريت اطلاعات راهها و نوينسازی سامانهها آغاز شد و در ساختمان شماره ۳ سازمان راهداری و حمل و نقل جادهای مستقر گرديد. هدف اوليه شكلگيری مركز، توسعه فناوریها و مديريت اطلاعات ترافيك بود، نه مديريت عمليات جادهای.",
|
||||
"detection_title": "نحوه شناسایی وقایع جادهای در ۱۴۱",
|
||||
"detection_intro": "شناسايی سريع وقايع جادهای از فعاليتهای اساسی مركز مديريت راهها است. اين فرآيند از طريق روشهای مختلف زير انجام میشود:",
|
||||
"detection_methods": {
|
||||
"traditional": "شناسايی با روشهای معمول و عرفی",
|
||||
"visual_tools": "شناسايی به كمك ابزار تصويری هوشمند",
|
||||
"online_systems": "شناسايی با استفاده از نتايج برخط سامانههای هوشمند"
|
||||
},
|
||||
"traditional_detection_title": "شناسايی سريعتر وقايع جادهای با روشهای معمولی",
|
||||
"traditional_detection_text": "از جمله روشهای معمول شناسايی وقايع جادهای میتوان به تماس كاربران جادهای، رانندگان حرفهای، گشتهای راه، و دريافت گزارش از ارگانهای ذیربط اشاره كرد. ارتباط منظم ميان شعبههای مركز مديريت راهها و ارگانهای استانی نقش مهمی در تسريع اطلاعرسانی دارد.",
|
||||
"smart_detection_title": "شناسايی سريعتر وقايع جادهای با استفاده از سامانههای هوشمند",
|
||||
"smart_detection_text": "استفاده از دوربينهای نظارتی هوشمند، دادههای برخط ترافيك شمارها و موقعيتياب GPS ماشينآلات، امكان شناسايی خودكار انسدادهای جادهای و تخمين زمان سفر را فراهم میكند.",
|
||||
"emergency_contacts_title": "تلفن تماسهای اضطراری",
|
||||
"emergency_contacts": {
|
||||
"road_info": "اطلاع رسانی راههای کشور",
|
||||
"emergency": "سازمان اورژانس کشور",
|
||||
"police": "پلیس",
|
||||
"fire_department": "آتش نشانی",
|
||||
"intel": "ستاد خبری وزارت اطلاعات",
|
||||
"toll_payment": "پرداخت آسان عوارض آزادراهی",
|
||||
"ikco_assistance": "امداد خودرو ایران خودرو",
|
||||
"saipa_assistance": "امداد خودرو سایپا",
|
||||
"imam_airport": "اطلاعات پرواز فرودگاه امام خمینی",
|
||||
"red_crescent": "ندای امداد جمعیت هلال احمر",
|
||||
"meteorology": "سازمان هواشناسی",
|
||||
"travel_services": "ستاد مرکزی هماهنگی خدمات سفر",
|
||||
"railways": "اطلاعات راه آهن",
|
||||
"flights": "اطلاعات پروازهای کشور"
|
||||
}
|
||||
},
|
||||
"GeolocationError": {
|
||||
"permission_denied": "اجازه دسترسی به لوکیشن وجود ندارد",
|
||||
"position_unavailable": "مکان فعلی یافت نشد",
|
||||
"timeout": "دوباره تلاش کنید",
|
||||
"unknown_error": "خطای ناشناخته"
|
||||
},
|
||||
"ObstructionList": {
|
||||
"search": "اعمال فیلتر",
|
||||
"obstruction_cause": "علت انسداد: ",
|
||||
"show_map": "نمایش نقشه",
|
||||
"news_text": "متن خبر",
|
||||
"direction": "جهت: ",
|
||||
"forward": "مسیر رفت",
|
||||
"backward": "مسیر برگشت",
|
||||
"both": "هردو جهت",
|
||||
"no_data": "موردی یافت نشد..."
|
||||
},
|
||||
"LimitationList": {
|
||||
"search": "اعمال فیلتر",
|
||||
"obstruction_cause": "علت انسداد: ",
|
||||
"show_map": "نمایش نقشه",
|
||||
"news_text": "متن خبر",
|
||||
"close": "بستن",
|
||||
"direction": "جهت: ",
|
||||
"limitation_type": "نوع وسیله نقلیه: ",
|
||||
"forward": "مسیر رفت",
|
||||
"backward": "مسیر برگشت",
|
||||
"both": "هردو جهت",
|
||||
"no_data": "موردی یافت نشد..."
|
||||
},
|
||||
"RoadWorkList": {
|
||||
"search": "اعمال فیلتر",
|
||||
"obstruction_cause": "علت انسداد: ",
|
||||
"show_map": "نمایش نقشه",
|
||||
"news_text": "متن خبر",
|
||||
"close": "بستن",
|
||||
"direction": "جهت: ",
|
||||
"limitation_type": "نوع وسیله نقلیه: ",
|
||||
"forward": "مسیر رفت",
|
||||
"backward": "مسیر برگشت",
|
||||
"both": "هردو جهت",
|
||||
"no_data": "موردی یافت نشد..."
|
||||
},
|
||||
"RoadWeather": {
|
||||
"description": "توضیحات: ",
|
||||
"risk_type": "نوع خطر: ",
|
||||
"affected_region": "منطقه تحت تاثیر: ",
|
||||
"start_date": "تاریخ شروع: ",
|
||||
"updated_at": "آخرین بروزرسانی: ",
|
||||
"end_date": "تاریخ پایان: "
|
||||
},
|
||||
"CustomLayouts": {
|
||||
"road_cameras_title": "تصاویر دوربین های جاده ای",
|
||||
"news_title": "اخبار",
|
||||
"favorites": "مورد علاقه ها",
|
||||
"profile": "جزییات حساب کاربری",
|
||||
"road-weather": "اطلاعیه\u200Cهای هواشناسی جاده\u200Cای",
|
||||
"limitations": "محدودیت های تردد",
|
||||
"obstruction-list": "لیست انسداد ها",
|
||||
"latest-roads-state": "آخرین وضعیت راه های کشور",
|
||||
"road-works": "کارگاه جاده ای",
|
||||
"corridors": "لیست کریدورها",
|
||||
"police-traffic-restrictions": "\nمحدودیت\u200Cهای اعلامی پلیس راه",
|
||||
"title": "لیست تصاویر"
|
||||
},
|
||||
"RoadCameras": {
|
||||
"road_cameras_title": "تصاویر دوربین های جاده ای",
|
||||
"news_title": "اخبار",
|
||||
"title": "لیست تصاویر",
|
||||
"select_province": "انتخاب استان",
|
||||
"route": "مسیر",
|
||||
"search": "جستجو",
|
||||
"from": "مبدا",
|
||||
"to": "مقصد",
|
||||
"kilometer": "کیلومتر",
|
||||
"minute": "دقیقه",
|
||||
"hour": "ساعت",
|
||||
"show_roads_on_map": "نمایش روی نقشه",
|
||||
"distance": "فاصله",
|
||||
"duration": "زمان",
|
||||
"footer": "تمامی حقوق این وبسایت متعلق به مرکز مدیریت راه های کشور میباشد."
|
||||
},
|
||||
"Notifications": {
|
||||
"code": "کد",
|
||||
"error": "خطا",
|
||||
"title": "درخواست شما ارسال شد",
|
||||
"warning": "خطا",
|
||||
"success": "موفق",
|
||||
"error_static_text": "عملیات شما با خطا مواجه شد",
|
||||
"warning_static_text": "خطا سرور",
|
||||
"success_static_text": "عملیات شما با موفقیت انجام شد",
|
||||
"pending": "در حال انجام..."
|
||||
},
|
||||
"mapServices": {
|
||||
"obstructions": "انسدادها",
|
||||
"cameras": "دوربینها",
|
||||
"map_services/cameras": "دوربینها",
|
||||
"no_data_for_service": "دادهای در این ناحیه یافت نشد",
|
||||
"petrol_stations": "جایگاه های سوخت",
|
||||
"car_assistances": "امداد فنی خودرو",
|
||||
"map_services/weather_forecasts": "پیش بینی وضعیت آب و هوا",
|
||||
"weather": "وضعیت آب و هوا",
|
||||
"welfare_services": "مجتمع های خدمات رفاهی",
|
||||
"mosques": "مساجد",
|
||||
"hadde_tarakhos": "حد ترخص",
|
||||
"tollhouses": "راهدارخانه",
|
||||
"repair_shops": "تعمیرگاه",
|
||||
"hospitals": "مراکز درمانی",
|
||||
"accidents": "تصادفات",
|
||||
"road_workshops": "کارگاه جاده ای",
|
||||
"otfs": "ترددشمار",
|
||||
"tolls": "عوارضی آزادراه",
|
||||
"active_services": "سرویس های فعال"
|
||||
},
|
||||
"Routing": {
|
||||
"routing": "مسیریابی",
|
||||
"origin": "مبدا",
|
||||
"destination": "مقصد",
|
||||
"sourcePlaceholder": "مبدأ را وارد کنید",
|
||||
"routingError": "خطا در مسیریابی",
|
||||
"searchDestinationOrChooseOnMap": "مقصد را جستجو کنید یا روی نقشه کلیک کنید",
|
||||
"searchOriginOrChooseOnMap": "مبدا را جستجو کنید یا روی نقشه کلیک کنید",
|
||||
"loading": "درحال بارگذاری...",
|
||||
"noSearchResults": "نتیجهای یافت نشد...",
|
||||
"useMyLocation": "استفاده از موقعیت من",
|
||||
"addDestination": "افزودن مقصد جدید"
|
||||
},
|
||||
"RoutingPreviewBox": {
|
||||
"route": "مسیر {index} ",
|
||||
"minute": "دقیقه",
|
||||
"hour": "ساعت",
|
||||
"details": "جزئیات مسیر",
|
||||
"share": "اشتراک\u200Cگذاری",
|
||||
"repeat": "بازنشانی",
|
||||
"drive": "رانندگی",
|
||||
"kilometer": "کیلومتر"
|
||||
},
|
||||
"SearchBox": {
|
||||
"search": "جستجو در نقشه 141"
|
||||
},
|
||||
"fakeFavoriteLocation": {
|
||||
"home": "خانه",
|
||||
"work": "محل کار",
|
||||
"others": "سایر ذخیره ها"
|
||||
},
|
||||
"Corridors": {
|
||||
"title": "لیست کریدورها",
|
||||
"fav-corridors": "کریدورهای موردعلاقه",
|
||||
"fav-cameras": "دوربین های موردعلاقه",
|
||||
"corridor_specification": "مشخصات کریدور",
|
||||
"corridor_sights": "جاذبه های گردشگری"
|
||||
},
|
||||
"LocationDrawer": {
|
||||
"select_destination": "انتخاب مقصد",
|
||||
"confirm_destination": "تایید مقصد",
|
||||
"confirm_origin": "تایید مبدا",
|
||||
"confirm_waypoint": "تایید مقصد بعدی",
|
||||
"back": "بازگشت",
|
||||
"loading": "درحال دریافت داده...",
|
||||
"route": "مسیرها",
|
||||
"share": "ارسال",
|
||||
"save": "ذخیره",
|
||||
"more_detail": "نمایش\u200Cبیشتر",
|
||||
"no_result": "نتیجه\u200Cای یافت نشد...",
|
||||
"select_beginning": "انتخاب مبدا",
|
||||
"road_cameras": "تصاویر دوربین های جاده ای",
|
||||
"target_location": "انتخاب موقعیت مکانی شما",
|
||||
"where_to": "جستجو در 141",
|
||||
"choose_source": "مبدا را انتخاب کنید",
|
||||
"choose_from_map": "انتخاب از روی نقشه"
|
||||
},
|
||||
"Sidebar": {
|
||||
"showSideMenu": "نمایش منو کناری",
|
||||
"login": "ورود",
|
||||
"application": "اپلیکیشن",
|
||||
"fav_corridors": "کریدورهای مورد علاقه",
|
||||
"fav_cameras": "دوربین های مورد علاقه",
|
||||
"register": "ثبت نام",
|
||||
"corridors": "کریدورها",
|
||||
"complaints": "ثبت شکایات",
|
||||
"cameraImages": "تصاویر دوربین ها",
|
||||
"road&traffic": "راه و ترافیک",
|
||||
"mainCorridors": "کریدورهای اصلی",
|
||||
"audioNews": "اخبار صوتی",
|
||||
"news": "اخبار",
|
||||
"audio_news": "اخبار صوتی",
|
||||
"policy": "تمامی حقوق این وبسایت متعلق به مرکز مدیریت راه های کشور می باشد",
|
||||
"supportText": "پشتیبانی آنلاین ۱۴۱",
|
||||
"lightMode": "حالت روز",
|
||||
"darkMode": "حالت شب",
|
||||
"support": "پشتیبانی",
|
||||
"history": "تاریخچه",
|
||||
"saved": "ذخیره شده"
|
||||
},
|
||||
"Auth": {
|
||||
"description": "با افتتاح حساب کاربری در سامانه ۱۴۱ می\u200Cتوانید از داده\u200Cهای تخصصی حمل و نقل، ثبت و پیگیری شکایات در کلیه حوزه\u200Cهای حمل و نقل جاده\u200Cای و بسیاری از خدمات دیگر بهره\u200Cمند شوید.",
|
||||
"userName": "نام کاربری",
|
||||
"login": "ورود",
|
||||
"profile": "حساب کاربری",
|
||||
"account": "حساب کاربری",
|
||||
"loginWithUserName": "ورود با نام کاربری",
|
||||
"loginWithPhoneNumber": "ورود با شماره همراه",
|
||||
"signup": "ثبت نام",
|
||||
"signInWithGovPortal": "ورود از طریق دولت من",
|
||||
"password": "رمز عبور",
|
||||
"passwordRequired": "رمز عبور خود را وارد کنید",
|
||||
"atLeast6Characters": "رمز عبور باید حداقل 6 کاراکتر باشد",
|
||||
"phoneNumberRequired": "شماره تلفن همراه خود را وارد کنید",
|
||||
"userNameRequired": "نام کاربری خود را وارد کنید",
|
||||
"enterValidPhoneNumber": "لطفا شماره تلفن همراه معتبر وارد کنید",
|
||||
"authWelcomeTitle": "به مرکز مدیریت راهای کشور خوش آمدید!",
|
||||
"authInfoText": "با افتتاح حساب کاربری در سامانه 141 میتوانید از دادههای تخصصی حمل و نقل، ثبت و پیگیری شکایات در کلیه حوزههای حمل و نقل جادهای و بسیاری از خدمات دیگر بهرهمند شوید.",
|
||||
"phoneNumber": "شماره تلفن همراه",
|
||||
"createAccount": "ایجاد حساب کاربری",
|
||||
"enteringAccount": "ورود به حساب کاربری",
|
||||
"sendCode": "ارسال کد",
|
||||
"accountRecovery": "بازیابی حساب",
|
||||
"resendOTP": "ارسال مجدد",
|
||||
"otpCode": "رمز یکبار مصرف",
|
||||
"logout": "خروج",
|
||||
"enterAcountDescription": "برای استفاده از تمامی امکانات وارد شوید."
|
||||
},
|
||||
"Complaints": {
|
||||
"MoshkelateRahForm": "فرم مشکلات راه",
|
||||
"description": "توضیحات",
|
||||
"complicationType": "نوع عارضه",
|
||||
"mapNote": "اگر مکان انتخابی در سطح محور نباشد شکایت شما ثبت نمی گردد لطفا مکان را دقیق در نقشه انتخاب کنید.",
|
||||
"selectedLocation": "محل انتخاب شده",
|
||||
"howToSelectLocation": "با جابجا کردن نقشه محل را انتخاب کنید",
|
||||
"clickForSelect": "برای انتخاب موقعیت کلیک کنید",
|
||||
"zoomForSelect": "برای انتخاب کردن موقعیت، زوم نقشه را بیشتر کنید!",
|
||||
"closeMap": "بستن نقشه",
|
||||
"editLocation": "ویرایش",
|
||||
"submitLocation": "ثبت مختصات و بستن نقشه",
|
||||
"BariDakheliSahebKalaForm": "فرم باری داخلی صاحب کالا",
|
||||
"BariDakheliRanandehForm": "فرم باری داخلی راننده",
|
||||
"BariDakheliBarbariForm": "فرم باری داخلی باربری",
|
||||
"BariPayaneHayeMarziRanandeh": "فرم باری پایانههای مرزی راننده",
|
||||
"MosaferiDakheliMosafer": "فرم مسافری داخلی مسافر",
|
||||
"MosaferiDakheliRanandeh": "فرم مسافری داخلی راننده",
|
||||
"MosaferiDakheliTaavoni": "فرم مسافری داخلی تعاونی",
|
||||
"MosaferiTransitMosafer": "فرم مسافری ترانزیت مسافر",
|
||||
"MosaferiPayaneHayeMarziRanandeh": "فرم مسافری پایانههای مرزی راننده",
|
||||
"MosaferiPayaneHayeMarziMosafer": "فرم مسافری پایانههای مرزی مسافر",
|
||||
"BariTransitRanandeh": "فرم باری ترانزیت راننده",
|
||||
"BariTransitSahebKala": "فرم باری ترانزیت صاحب کالا",
|
||||
"BariTransitBarbari": "فرم باری ترانزیت باربری",
|
||||
"category": "دسته بندی",
|
||||
"complaint_subtitle_id": "موضوع",
|
||||
"province_id": "استان",
|
||||
"first_fill_category": "ابتدا دسته بندی را انتخاب کنید",
|
||||
"first_fill_province": "ابتدا استان را انتخاب کنید",
|
||||
"bearing_id": "شرکت باربری",
|
||||
"followUpComplaints": "پیگیری شکایات ثبت شده",
|
||||
"backToComplaints": "بازگشت به شکایات",
|
||||
"addComplaint": "ثبت درخواست شکایت جدید",
|
||||
"showComplaintDetails": "نمایش جزییات نتیجه شکایت",
|
||||
"trackingCode": "کد رهگیری",
|
||||
"complaintStatus": "وضعیت",
|
||||
"plaintiff": "درخواست دهنده",
|
||||
"complaintSubject": "موضوع شکایت",
|
||||
"createdAt": "تاریخ ثبت شکایت"
|
||||
},
|
||||
"Bookmarks": {
|
||||
"favoriteLocations": "مکانهای مورد علاقه",
|
||||
"home": "خانه",
|
||||
"work": "محل کار",
|
||||
"others": "سایر",
|
||||
"addBookmark": "افزودن مکان مورد علاقه",
|
||||
"saveBookmark": "ذخیره",
|
||||
"confirmDeleteText": "آیا از حذف این مکان مطمئن هستید؟",
|
||||
"delete": "حذف",
|
||||
"abort": "انصراف",
|
||||
"signin": "ورود",
|
||||
"signInModalTitle": "ورود به حساب کاربری",
|
||||
"signInModalDescription": "برای استفاده از این قابلیت باید وارد حساب کاربری خود شوید."
|
||||
},
|
||||
"ContextMenu": {
|
||||
"routingFromHere": "مسیریابی از اینجا",
|
||||
"routingToHere": "مسیریابی به اینجا",
|
||||
"copyCoordinates": "کپی کردن مختصات",
|
||||
"clearRoutes": "پاک کردن مسیرها"
|
||||
},
|
||||
"logout": {
|
||||
"logout": "خروج",
|
||||
"confirmLogout": "آیا از خروج خود اطمینان دارید؟",
|
||||
"cancel": "انصراف"
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
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);
|
||||
|
||||
86
package.json
@@ -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
@@ -1,5 +1,5 @@
|
||||
packages:
|
||||
- .
|
||||
ignoredBuiltDependencies:
|
||||
- sharp
|
||||
- unrs-resolver
|
||||
packages:
|
||||
- .
|
||||
ignoredBuiltDependencies:
|
||||
- sharp
|
||||
- unrs-resolver
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
|
||||
|
Before Width: | Height: | Size: 391 B |
79
public/fonts.css
Normal file
@@ -0,0 +1,79 @@
|
||||
@font-face {
|
||||
font-family: "Ravi";
|
||||
src:
|
||||
url("./fonts/ravi/woff2/RaviFaNum-ExtraBlack.woff2") format("woff2"),
|
||||
url("./fonts/ravi/woff/RaviFaNum-ExtraBlack.woff") format("woff");
|
||||
font-weight: 900;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Ravi";
|
||||
src:
|
||||
url("./fonts/ravi/woff2/RaviFaNum-Black.woff2") format("woff2"),
|
||||
url("./fonts/ravi/woff/RaviFaNum-Black.woff") format("woff");
|
||||
font-weight: 800;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Ravi";
|
||||
src:
|
||||
url("./fonts/ravi/woff2/RaviFaNum-Bold.woff2") format("woff2"),
|
||||
url("./fonts/ravi/woff/RaviFaNum-Bold.woff") format("woff");
|
||||
font-weight: 700;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Ravi";
|
||||
src:
|
||||
url("./fonts/ravi/woff2/RaviFaNum-SemiBold.woff2") format("woff2"),
|
||||
url("./fonts/ravi/woff/RaviFaNum-SemiBold.woff") format("woff");
|
||||
font-weight: 600;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Ravi";
|
||||
src:
|
||||
url("./fonts/ravi/woff2/RaviFaNum-Medium.woff2") format("woff2"),
|
||||
url("./fonts/ravi/woff/RaviFaNum-Medium.woff") format("woff");
|
||||
font-weight: 500;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Ravi";
|
||||
src:
|
||||
url("./fonts/ravi/woff2/RaviFaNum-Regular.woff2") format("woff2"),
|
||||
url("./fonts/ravi/woff/RaviFaNum-Regular.woff") format("woff");
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Ravi";
|
||||
src:
|
||||
url("./fonts/ravi/woff2/RaviFaNum-Light.woff2") format("woff2"),
|
||||
url("./fonts/ravi/woff/RaviFaNum-Light.woff") format("woff");
|
||||
font-weight: 300;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Ravi";
|
||||
src:
|
||||
url("./fonts/ravi/woff2/RaviFaNum-Thin.woff2") format("woff2"),
|
||||
url("./fonts/ravi/woff/RaviFaNum-Thin.woff") format("woff");
|
||||
font-weight: 200;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
BIN
public/fonts/ravi/woff/RaviFaNum-Black.woff
Normal file
BIN
public/fonts/ravi/woff/RaviFaNum-Bold.woff
Normal file
BIN
public/fonts/ravi/woff/RaviFaNum-ExtraBlack.woff
Normal file
BIN
public/fonts/ravi/woff/RaviFaNum-Light.woff
Normal file
BIN
public/fonts/ravi/woff/RaviFaNum-Medium.woff
Normal file
BIN
public/fonts/ravi/woff/RaviFaNum-Regular.woff
Normal file
BIN
public/fonts/ravi/woff/RaviFaNum-SemiBold.woff
Normal file
BIN
public/fonts/ravi/woff/RaviFaNum-Thin.woff
Normal file
BIN
public/fonts/ravi/woff2/RaviFaNum-Black.woff2
Normal file
BIN
public/fonts/ravi/woff2/RaviFaNum-Bold.woff2
Normal file
BIN
public/fonts/ravi/woff2/RaviFaNum-ExtraBlack.woff2
Normal file
BIN
public/fonts/ravi/woff2/RaviFaNum-Light.woff2
Normal file
BIN
public/fonts/ravi/woff2/RaviFaNum-Medium.woff2
Normal file
BIN
public/fonts/ravi/woff2/RaviFaNum-Regular.woff2
Normal file
BIN
public/fonts/ravi/woff2/RaviFaNum-SemiBold.woff2
Normal file
BIN
public/fonts/ravi/woff2/RaviFaNum-Thin.woff2
Normal file
@@ -1 +0,0 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
||||
|
Before Width: | Height: | Size: 1.0 KiB |
4
public/icons/avatar-icon.svg
Normal 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 |
3
public/icons/check-icon.svg
Normal 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 |
3
public/icons/chevron-icon.svg
Normal 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 |
8
public/icons/close-icon.svg
Normal 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 |
3
public/icons/dark-mode-icon.svg
Normal 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 |
40
public/icons/google-maps.svg
Normal 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 |
3
public/icons/light-mode-icon.svg
Normal 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
@@ -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
|
After Width: | Height: | Size: 2.3 KiB |
BIN
public/logo/152px.png
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
BIN
public/logo/192px.png
Normal file
|
After Width: | Height: | Size: 2.9 KiB |
BIN
public/logo/384px.png
Normal file
|
After Width: | Height: | Size: 3.8 KiB |
BIN
public/logo/512px.png
Normal file
|
After Width: | Height: | Size: 3.8 KiB |
40
public/manifest.webmanifest
Normal 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
@@ -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
@@ -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
@@ -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();
|
||||
});
|
||||
@@ -1 +0,0 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
|
||||
|
Before Width: | Height: | Size: 128 B |
@@ -1 +0,0 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
|
||||
|
Before Width: | Height: | Size: 385 B |
3
src/app/(main)/(home)/Layout.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
export default async function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return <div className={"bg-bg"}>{children}</div>;
|
||||
}
|
||||
11
src/app/(main)/(home)/page.tsx
Normal 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 />;
|
||||
}
|
||||
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 4.5 KiB |
42
src/app/global-error.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
:root {
|
||||
--background: #ffffff;
|
||||
--foreground: #171717;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-geist-sans);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--background: #0a0a0a;
|
||||
--foreground: #ededed;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
}
|
||||
@@ -1,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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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";
|
||||
26
src/assets/translations/filepond-fa.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"labelInvalidField": "این فیلد شامل فایلهای نامعتبر است",
|
||||
"labelFileWaitingForSize": "در انتظار بررسی سایز فایل",
|
||||
"labelFileSizeNotAvailable": "اندازه فایل {fileName} موجود نیست",
|
||||
"labelFileLoading": "در حال بارگذاری",
|
||||
"labelFileLoadError": "خطا هنگام بارگذاری",
|
||||
"labelFileProcessing": "در حال آپلود",
|
||||
"labelFileProcessingComplete": "آپلود کامل شد",
|
||||
"labelFileProcessingAborted": "آپلود لغو شد",
|
||||
"labelFileProcessingError": "خطا هنگام آپلود",
|
||||
"labelTapToCancel": "برای لغو کلیک کنید",
|
||||
"labelTapToRetry": "برای تلاش دوباره کلیک کنید",
|
||||
"labelTapToUndo": "برای بازگشت کلیک کنید",
|
||||
"labelButtonRemoveItem": "حذف",
|
||||
"labelButtonAbortItemLoad": "لغو",
|
||||
"labelButtonRetryItemLoad": "تلاش دوباره",
|
||||
"labelButtonAbortItemProcessing": "لغو",
|
||||
"labelButtonRetryItemProcessing": "تلاش دوباره",
|
||||
"labelButtonProcessItem": "آپلود",
|
||||
"labelMaxFileSizeExceeded": "حجم فایل بیش از حد مجاز است",
|
||||
"labelMaxFileSize": "حداکثر حجم مجاز: {filesize}",
|
||||
"labelFileTypeNotAllowed": "نوع فایل {fileName} مجاز نیست",
|
||||
"labelFileSizeMegabytes": "مگابایت",
|
||||
"labelFileSizeKilobytes": "کیلوبایت",
|
||||
"labelFileSizeGigabytes": "گیگابایت"
|
||||
}
|
||||
3
src/components/Header/Desktop/index.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
export default function DesktopHeader() {
|
||||
return <></>;
|
||||
}
|
||||
3
src/components/Header/Mobile/index.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
export default function MobileHeader() {
|
||||
return <></>;
|
||||
}
|
||||
3
src/components/Home/Desktop/index.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
export default function DesktopHome() {
|
||||
return <></>;
|
||||
}
|
||||
3
src/components/Home/Mobile/index.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
export default function MobileHome() {
|
||||
return <></>;
|
||||
}
|
||||
39
src/components/Modals/LogoutModal.tsx
Normal 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;
|
||||
104
src/components/Modals/MapLauncherModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
36
src/components/Modals/RedirectToSignInModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
20
src/components/ServiceWorkerRegister.tsx
Normal 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;
|
||||
}
|
||||
33
src/components/SideBar/SideBarContent/MenuItem.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
42
src/components/SideBar/SideBarContent/SubMenu.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
51
src/components/SideBar/SideBarContent/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
131
src/components/SideBar/index.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
39
src/components/ThemeToggle/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
82
src/components/UI/BottomSheet.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
"use client";
|
||||
|
||||
import { useBottomSheetStore } from "@/stores/useBottomSheetStore";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { useEffect } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
const BottomSheet = () => {
|
||||
const { isOpen, content, options, closeBottomSheet } = useBottomSheetStore();
|
||||
|
||||
const interactive = options?.interactiveBackground ?? false;
|
||||
|
||||
const viewportHeight = typeof window !== "undefined" ? window.innerHeight : 0;
|
||||
|
||||
// Escape key (only active for modal mode)
|
||||
useEffect(() => {
|
||||
if (!isOpen || interactive) return;
|
||||
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") closeBottomSheet();
|
||||
};
|
||||
|
||||
document.addEventListener("keydown", handleEscape);
|
||||
document.body.style.overflow = "hidden";
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("keydown", handleEscape);
|
||||
document.body.style.overflow = "unset";
|
||||
};
|
||||
}, [isOpen, interactive, closeBottomSheet]);
|
||||
|
||||
return createPortal(
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<>
|
||||
{/* Non-interactive background mode (modal) */}
|
||||
{!interactive && (
|
||||
<motion.div
|
||||
key="modal-overlay"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.25 }}
|
||||
className="fixed inset-0 z-50 bg-black/50"
|
||||
onClick={closeBottomSheet}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* The sheet (works in both modes) */}
|
||||
<motion.div
|
||||
key="sheet"
|
||||
initial={{ y: viewportHeight }}
|
||||
animate={{ y: 0 }}
|
||||
exit={{ y: viewportHeight }}
|
||||
transition={{ type: "spring", damping: 30, stiffness: 300 }}
|
||||
drag="y"
|
||||
dragConstraints={{ top: 0, bottom: 0 }}
|
||||
dragElastic={{ top: 0, bottom: 0.25 }}
|
||||
onDragEnd={(e, info) => {
|
||||
if (info.offset.y > 100 || info.velocity.y > 500) {
|
||||
closeBottomSheet();
|
||||
}
|
||||
}}
|
||||
className="bg-pwa-primary fixed inset-x-0 bottom-0 z-[60] mx-auto w-full max-w-md rounded-t-xl shadow-lg"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex justify-center py-2">
|
||||
<div className="h-1 w-12 rounded-full bg-gray-400" />
|
||||
</div>
|
||||
|
||||
<div className="overflow-y-auto p-4" style={{ maxHeight: "90vh" }} dir="rtl">
|
||||
{content}
|
||||
</div>
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>,
|
||||
document.body
|
||||
);
|
||||
};
|
||||
|
||||
export default BottomSheet;
|
||||
161
src/components/UI/Calendar.tsx
Normal file
@@ -0,0 +1,161 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import * as React from "react";
|
||||
import { Chevron, DayButton, getDefaultClassNames } from "react-day-picker";
|
||||
import { DayPicker } from "react-day-picker/persian";
|
||||
|
||||
interface CalendarHijriProps {
|
||||
selected?: Date;
|
||||
onSelect?: (date: Date | undefined) => void;
|
||||
defaultMonth?: Date;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function CalendarHijri({ selected, onSelect, defaultMonth, className }: CalendarHijriProps) {
|
||||
return (
|
||||
<Calendar
|
||||
mode="single"
|
||||
defaultMonth={defaultMonth ?? selected ?? new Date()}
|
||||
selected={selected}
|
||||
onSelect={onSelect}
|
||||
className={cn("rounded-lg shadow-sm", className)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function Calendar({
|
||||
className,
|
||||
classNames,
|
||||
showOutsideDays = true,
|
||||
captionLayout = "label",
|
||||
formatters,
|
||||
components,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DayPicker>) {
|
||||
const defaultClassNames = getDefaultClassNames();
|
||||
|
||||
return (
|
||||
<DayPicker
|
||||
showOutsideDays={showOutsideDays}
|
||||
className={cn("bg-text text-desktop-primary p-3 [--cell-size:--spacing(10)]", className)}
|
||||
captionLayout={captionLayout}
|
||||
formatters={{
|
||||
formatMonthDropdown: (date) => date.toLocaleString("default", { month: "short" }),
|
||||
...formatters,
|
||||
}}
|
||||
classNames={{
|
||||
root: cn("w-fit", defaultClassNames.root),
|
||||
months: cn("flex gap-4 flex-col md:flex-row relative", defaultClassNames.months),
|
||||
month: cn("flex flex-col w-full gap-3", defaultClassNames.month),
|
||||
nav: cn("flex items-center w-full absolute top-0 inset-x-0 justify-between", defaultClassNames.nav),
|
||||
button_previous: cn(
|
||||
"size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
|
||||
defaultClassNames.button_previous
|
||||
),
|
||||
button_next: cn(
|
||||
"size-(--cell-size) aria-disabled:opacity-50 select-none",
|
||||
defaultClassNames.button_next
|
||||
),
|
||||
month_caption: cn(
|
||||
"flex items-center justify-center h-(--cell-size) w-full px-(--cell-size)",
|
||||
defaultClassNames.month_caption
|
||||
),
|
||||
dropdowns: cn(
|
||||
"w-full flex items-center text-sm font-medium justify-center h-(--cell-size) gap-1.5",
|
||||
defaultClassNames.dropdowns
|
||||
),
|
||||
dropdown_root: cn(
|
||||
"relative has-focus:border-ring border border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] rounded-md",
|
||||
defaultClassNames.dropdown_root
|
||||
),
|
||||
dropdown: cn("absolute inset-0 opacity-0", defaultClassNames.dropdown),
|
||||
caption_label: cn(
|
||||
"select-none font-medium",
|
||||
captionLayout === "label"
|
||||
? "text-sm"
|
||||
: "rounded-md pl-2 pr-1 flex items-center gap-1 text-sm h-8 [&>svg]:text-text-desktop-primary [&>svg]:size-3.5",
|
||||
defaultClassNames.caption_label
|
||||
),
|
||||
table: "w-full border-collapse",
|
||||
weekdays: cn("flex items-center justify-center", defaultClassNames.weekdays),
|
||||
weekday: cn(
|
||||
"text-neo-aqua rounded-md flex-1 font-normal text-sm select-none",
|
||||
defaultClassNames.weekday
|
||||
),
|
||||
week: cn("flex w-full mt-2", defaultClassNames.week),
|
||||
week_number_header: cn("select-none w-(--cell-size)", defaultClassNames.week_number_header),
|
||||
week_number: cn("text-sm select-none text-desktop-primary", defaultClassNames.week_number),
|
||||
day: cn(
|
||||
"relative w-full h-full text-center [&:first-child[data-selected=true]_button]:rounded-l-md [&:last-child[data-selected=true]_button]:rounded-r-md group/day aspect-square select-none",
|
||||
defaultClassNames.day
|
||||
),
|
||||
range_start: cn("rounded-l-md bg-accent", defaultClassNames.range_start),
|
||||
range_middle: cn("rounded-none", defaultClassNames.range_middle),
|
||||
range_end: cn("rounded-r-md bg-accent", defaultClassNames.range_end),
|
||||
today: cn(
|
||||
"bg-neo-aqua/10 text-accent-foreground rounded-md data-[selected=true]:rounded-none",
|
||||
defaultClassNames.today
|
||||
),
|
||||
outside: cn(
|
||||
"text-text-desktop-primary aria-selected:text-text-desktop-primary",
|
||||
defaultClassNames.outside
|
||||
),
|
||||
disabled: cn("text-text-desktop-primary opacity-50", defaultClassNames.disabled),
|
||||
hidden: cn("invisible", defaultClassNames.hidden),
|
||||
...classNames,
|
||||
}}
|
||||
components={{
|
||||
Root: ({ className, rootRef, ...props }) => (
|
||||
<div data-slot="calendar" ref={rootRef} className={cn(className)} {...props} />
|
||||
),
|
||||
Chevron: ({ className, orientation, ...props }) => {
|
||||
if (orientation === "left") {
|
||||
return <Chevron className={cn("size-4 rotate-180", className)} {...props} />;
|
||||
}
|
||||
if (orientation === "right") {
|
||||
return <Chevron className={cn("size-4 justify-self-end", className)} {...props} />;
|
||||
}
|
||||
return <Chevron className={cn("size-4 rotate-90", className)} {...props} />;
|
||||
},
|
||||
DayButton: CalendarDayButton,
|
||||
WeekNumber: ({ children, ...props }) => (
|
||||
<td {...props}>
|
||||
<div className="flex size-(--cell-size) items-center justify-center text-center">
|
||||
{children}
|
||||
</div>
|
||||
</td>
|
||||
),
|
||||
...components,
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CalendarDayButton({ className, day, modifiers, ...props }: React.ComponentProps<typeof DayButton>) {
|
||||
const defaultClassNames = getDefaultClassNames();
|
||||
const ref = React.useRef<HTMLButtonElement>(null);
|
||||
React.useEffect(() => {
|
||||
if (modifiers.focused) ref.current?.focus();
|
||||
}, [modifiers.focused]);
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
data-day={day.date.toLocaleDateString()}
|
||||
data-selected-single={
|
||||
modifiers.selected && !modifiers.range_start && !modifiers.range_end && !modifiers.range_middle
|
||||
}
|
||||
data-range-start={modifiers.range_start}
|
||||
data-range-end={modifiers.range_end}
|
||||
data-range-middle={modifiers.range_middle}
|
||||
className={cn(
|
||||
"data-[selected-single=true]:bg-neo-aqua data-[selected-single=true]:text-text data-[range-middle=true]:bg-neo-aqua/20 data-[range-middle=true]:text-text data-[range-start=true]:bg-neo-aqua/20 data-[range-start=true]:text-text data-[range-end=true]:bg-neo-aqua/20 data-[range-end=true]:text-text flex aspect-square size-auto w-full min-w-(--cell-size) flex-col items-center justify-center gap-1 rounded leading-none group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:scale-90 data-[range-end=true]:rounded-md data-[range-end=true]:rounded-r-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md data-[range-start=true]:rounded-l-md [&>span]:text-xs [&>span]:opacity-70",
|
||||
defaultClassNames.day,
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
66
src/components/UI/Modal.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
"use client";
|
||||
|
||||
import { useModalStore } from "@/stores/useModalStore";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
const Modal = () => {
|
||||
const isOpen = useModalStore((s) => s.isOpen);
|
||||
const content = useModalStore((s) => s.content);
|
||||
const closeModal = useModalStore((s) => s.closeModal);
|
||||
|
||||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Handle escape key to close
|
||||
useEffect(() => {
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") closeModal();
|
||||
};
|
||||
if (isOpen) {
|
||||
document.addEventListener("keydown", handleEscape);
|
||||
document.body.style.overflow = "hidden"; // Prevent scrolling
|
||||
}
|
||||
return () => {
|
||||
document.removeEventListener("keydown", handleEscape);
|
||||
document.body.style.overflow = "unset";
|
||||
};
|
||||
}, [isOpen, closeModal]);
|
||||
|
||||
const handleClickOutside = (e: React.MouseEvent) => {
|
||||
if (modalRef.current && !modalRef.current.contains(e.target as Node)) {
|
||||
closeModal();
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return createPortal(
|
||||
<AnimatePresence>
|
||||
<motion.div
|
||||
role="dialog"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
|
||||
onClick={handleClickOutside}
|
||||
>
|
||||
<motion.div
|
||||
ref={modalRef}
|
||||
initial={{ scale: 0.95, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
exit={{ scale: 0.95, opacity: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="relative rounded-lg shadow-lg"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{content}
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</AnimatePresence>,
|
||||
document.body
|
||||
);
|
||||
};
|
||||
|
||||
export default Modal;
|
||||
42
src/components/UI/Popover.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as PopoverPrimitive from "@radix-ui/react-popover";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Popover({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Root>) {
|
||||
return <PopoverPrimitive.Root data-slot="popover" {...props} />;
|
||||
}
|
||||
|
||||
function PopoverTrigger({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
|
||||
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />;
|
||||
}
|
||||
|
||||
function PopoverContent({
|
||||
className,
|
||||
align = "center",
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
|
||||
return (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Content
|
||||
data-slot="popover-content"
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
|
||||
function PopoverAnchor({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
|
||||
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />;
|
||||
}
|
||||
|
||||
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor };
|
||||
147
src/components/UI/Select.tsx
Normal file
@@ -0,0 +1,147 @@
|
||||
"use client";
|
||||
import * as React from "react";
|
||||
import * as SelectPrimitive from "@radix-ui/react-select";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { CheckIcon, ChevronIcon } from "@/assets";
|
||||
function Select({ ...props }: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
||||
return <SelectPrimitive.Root data-slot="select" {...props} />;
|
||||
}
|
||||
function SelectGroup({ ...props }: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
||||
return <SelectPrimitive.Group data-slot="select-group" {...props} />;
|
||||
}
|
||||
function SelectValue({ ...props }: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
||||
return <SelectPrimitive.Value data-slot="select-value" {...props} />;
|
||||
}
|
||||
function SelectTrigger({
|
||||
className,
|
||||
size = "default",
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
|
||||
size?: "sm" | "default";
|
||||
}) {
|
||||
return (
|
||||
<SelectPrimitive.Trigger
|
||||
data-slot="select-trigger"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronIcon className="text-text size-4 rotate-270" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
);
|
||||
}
|
||||
function SelectContent({
|
||||
className,
|
||||
children,
|
||||
position = "popper",
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
|
||||
return (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
data-slot="select-content"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
function SelectLabel({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.Label>) {
|
||||
return (
|
||||
<SelectPrimitive.Label
|
||||
data-slot="select-label"
|
||||
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
function SelectItem({ className, children, ...props }: React.ComponentProps<typeof SelectPrimitive.Item>) {
|
||||
return (
|
||||
<SelectPrimitive.Item
|
||||
data-slot="select-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<CheckIcon size={4} />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
);
|
||||
}
|
||||
function SelectSeparator({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.Separator>) {
|
||||
return (
|
||||
<SelectPrimitive.Separator
|
||||
data-slot="select-separator"
|
||||
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
function SelectScrollUpButton({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
data-slot="select-scroll-up-button"
|
||||
className={cn("flex cursor-default items-center justify-center py-1", className)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronIcon className="text-desktop-primary size-4 rotate-90" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
);
|
||||
}
|
||||
function SelectScrollDownButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
data-slot="select-scroll-down-button"
|
||||
className={cn("flex cursor-default items-center justify-center py-1", className)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronIcon className="text-desktop-primary size-4 rotate-270" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
);
|
||||
}
|
||||
export {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectScrollDownButton,
|
||||
SelectScrollUpButton,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
};
|
||||
14
src/components/UI/Separator.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import { HTMLAttributes } from "react";
|
||||
|
||||
type SeparatorProps = {
|
||||
className?: HTMLAttributes<HTMLDivElement> | string | undefined;
|
||||
vertical?: boolean;
|
||||
};
|
||||
|
||||
export default function Separator({ className, vertical }: SeparatorProps) {
|
||||
if (vertical) {
|
||||
return <div className={cn("border-text/15 h-full border-r", className)} />;
|
||||
}
|
||||
return <div className={cn("border-text/15 w-full border-b", className)} />;
|
||||
}
|
||||
492
src/components/UI/Sortable.tsx
Normal file
@@ -0,0 +1,492 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
type Announcements,
|
||||
closestCenter,
|
||||
closestCorners,
|
||||
DndContext,
|
||||
type DndContextProps,
|
||||
type DragEndEvent,
|
||||
type DraggableAttributes,
|
||||
type DraggableSyntheticListeners,
|
||||
DragOverlay,
|
||||
type DragStartEvent,
|
||||
type DropAnimation,
|
||||
defaultDropAnimationSideEffects,
|
||||
KeyboardSensor,
|
||||
MouseSensor,
|
||||
type ScreenReaderInstructions,
|
||||
TouchSensor,
|
||||
type UniqueIdentifier,
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from "@dnd-kit/core";
|
||||
import { restrictToHorizontalAxis, restrictToParentElement, restrictToVerticalAxis } from "@dnd-kit/modifiers";
|
||||
import {
|
||||
arrayMove,
|
||||
horizontalListSortingStrategy,
|
||||
SortableContext,
|
||||
type SortableContextProps,
|
||||
sortableKeyboardCoordinates,
|
||||
useSortable,
|
||||
verticalListSortingStrategy,
|
||||
} from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import * as React from "react";
|
||||
import * as ReactDOM from "react-dom";
|
||||
import { useComposedRefs } from "@/lib/compose-refs";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const orientationConfig = {
|
||||
vertical: {
|
||||
modifiers: [restrictToVerticalAxis, restrictToParentElement],
|
||||
strategy: verticalListSortingStrategy,
|
||||
collisionDetection: closestCenter,
|
||||
},
|
||||
horizontal: {
|
||||
modifiers: [restrictToHorizontalAxis, restrictToParentElement],
|
||||
strategy: horizontalListSortingStrategy,
|
||||
collisionDetection: closestCenter,
|
||||
},
|
||||
mixed: {
|
||||
modifiers: [restrictToParentElement],
|
||||
strategy: undefined,
|
||||
collisionDetection: closestCorners,
|
||||
},
|
||||
};
|
||||
|
||||
const ROOT_NAME = "Sortable";
|
||||
const CONTENT_NAME = "SortableContent";
|
||||
const ITEM_NAME = "SortableItem";
|
||||
const ITEM_HANDLE_NAME = "SortableItemHandle";
|
||||
const OVERLAY_NAME = "SortableOverlay";
|
||||
|
||||
interface SortableRootContextValue<T> {
|
||||
id: string;
|
||||
items: UniqueIdentifier[];
|
||||
modifiers: DndContextProps["modifiers"];
|
||||
strategy: SortableContextProps["strategy"];
|
||||
activeId: UniqueIdentifier | null;
|
||||
setActiveId: (id: UniqueIdentifier | null) => void;
|
||||
getItemValue: (item: T) => UniqueIdentifier;
|
||||
flatCursor: boolean;
|
||||
}
|
||||
|
||||
const SortableRootContext = React.createContext<SortableRootContextValue<unknown> | null>(null);
|
||||
|
||||
function useSortableContext(consumerName: string) {
|
||||
const context = React.useContext(SortableRootContext);
|
||||
if (!context) {
|
||||
throw new Error(`\`${consumerName}\` must be used within \`${ROOT_NAME}\``);
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
interface GetItemValue<T> {
|
||||
/**
|
||||
* Callback that returns a unique identifier for each sortable item. Required for array of objects.
|
||||
* @example getItemValue={(item) => item.id}
|
||||
*/
|
||||
getItemValue: (item: T) => UniqueIdentifier;
|
||||
}
|
||||
|
||||
type SortableRootProps<T> = DndContextProps &
|
||||
(T extends object ? GetItemValue<T> : Partial<GetItemValue<T>>) & {
|
||||
value: T[];
|
||||
onValueChange?: (items: T[]) => void;
|
||||
onMove?: (event: DragEndEvent & { activeIndex: number; overIndex: number }) => void;
|
||||
strategy?: SortableContextProps["strategy"];
|
||||
orientation?: "vertical" | "horizontal" | "mixed";
|
||||
flatCursor?: boolean;
|
||||
};
|
||||
|
||||
function SortableRoot<T>(props: SortableRootProps<T>) {
|
||||
const {
|
||||
value,
|
||||
onValueChange,
|
||||
collisionDetection,
|
||||
modifiers,
|
||||
strategy,
|
||||
onMove,
|
||||
orientation = "vertical",
|
||||
flatCursor = false,
|
||||
getItemValue: getItemValueProp,
|
||||
accessibility,
|
||||
...sortableProps
|
||||
} = props;
|
||||
|
||||
const id = React.useId();
|
||||
const [activeId, setActiveId] = React.useState<UniqueIdentifier | null>(null);
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(MouseSensor),
|
||||
useSensor(TouchSensor),
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates,
|
||||
})
|
||||
);
|
||||
const config = React.useMemo(() => orientationConfig[orientation], [orientation]);
|
||||
|
||||
const getItemValue = React.useCallback(
|
||||
(item: T): UniqueIdentifier => {
|
||||
if (typeof item === "object" && !getItemValueProp) {
|
||||
throw new Error("getItemValue is required when using array of objects");
|
||||
}
|
||||
return getItemValueProp ? getItemValueProp(item) : (item as UniqueIdentifier);
|
||||
},
|
||||
[getItemValueProp]
|
||||
);
|
||||
|
||||
const items = React.useMemo(() => {
|
||||
return value.map((item) => getItemValue(item));
|
||||
}, [value, getItemValue]);
|
||||
|
||||
const onDragStart = (event: DragStartEvent) => {
|
||||
sortableProps.onDragStart?.(event);
|
||||
|
||||
if (event.activatorEvent.defaultPrevented) return;
|
||||
|
||||
setActiveId(event.active.id);
|
||||
};
|
||||
|
||||
const onDragEnd = (event: DragEndEvent) => {
|
||||
sortableProps.onDragEnd?.(event);
|
||||
|
||||
if (event.activatorEvent.defaultPrevented) return;
|
||||
|
||||
const { active, over } = event;
|
||||
if (over && active.id !== over?.id) {
|
||||
const activeIndex = value.findIndex((item) => getItemValue(item) === active.id);
|
||||
const overIndex = value.findIndex((item) => getItemValue(item) === over.id);
|
||||
|
||||
if (onMove) {
|
||||
onMove({ ...event, activeIndex, overIndex });
|
||||
} else {
|
||||
onValueChange?.(arrayMove(value, activeIndex, overIndex));
|
||||
}
|
||||
}
|
||||
setActiveId(null);
|
||||
};
|
||||
|
||||
const onDragCancel = (event: DragEndEvent) => {
|
||||
sortableProps.onDragCancel?.(event);
|
||||
|
||||
if (event.activatorEvent.defaultPrevented) return;
|
||||
|
||||
setActiveId(null);
|
||||
};
|
||||
|
||||
const announcements: Announcements = React.useMemo(
|
||||
() => ({
|
||||
onDragStart({ active }) {
|
||||
const activeValue = active.id.toString();
|
||||
return `Grabbed sortable item "${activeValue}". Current position is ${active.data.current?.sortable.index + 1} of ${value.length}. Use arrow keys to move, space to drop.`;
|
||||
},
|
||||
onDragOver({ active, over }) {
|
||||
if (over) {
|
||||
const overIndex = over.data.current?.sortable.index ?? 0;
|
||||
const activeIndex = active.data.current?.sortable.index ?? 0;
|
||||
const moveDirection = overIndex > activeIndex ? "down" : "up";
|
||||
const activeValue = active.id.toString();
|
||||
return `Sortable item "${activeValue}" moved ${moveDirection} to position ${overIndex + 1} of ${value.length}.`;
|
||||
}
|
||||
return "Sortable item is no longer over a droppable area. Press escape to cancel.";
|
||||
},
|
||||
onDragEnd({ active, over }) {
|
||||
const activeValue = active.id.toString();
|
||||
if (over) {
|
||||
const overIndex = over.data.current?.sortable.index ?? 0;
|
||||
return `Sortable item "${activeValue}" dropped at position ${overIndex + 1} of ${value.length}.`;
|
||||
}
|
||||
return `Sortable item "${activeValue}" dropped. No changes were made.`;
|
||||
},
|
||||
onDragCancel({ active }) {
|
||||
const activeIndex = active.data.current?.sortable.index ?? 0;
|
||||
const activeValue = active.id.toString();
|
||||
return `Sorting cancelled. Sortable item "${activeValue}" returned to position ${activeIndex + 1} of ${value.length}.`;
|
||||
},
|
||||
onDragMove({ active, over }) {
|
||||
if (over) {
|
||||
const overIndex = over.data.current?.sortable.index ?? 0;
|
||||
const activeIndex = active.data.current?.sortable.index ?? 0;
|
||||
const moveDirection = overIndex > activeIndex ? "down" : "up";
|
||||
const activeValue = active.id.toString();
|
||||
return `Sortable item "${activeValue}" is moving ${moveDirection} to position ${overIndex + 1} of ${value.length}.`;
|
||||
}
|
||||
return "Sortable item is no longer over a droppable area. Press escape to cancel.";
|
||||
},
|
||||
}),
|
||||
[value]
|
||||
);
|
||||
|
||||
const screenReaderInstructions: ScreenReaderInstructions = React.useMemo(
|
||||
() => ({
|
||||
draggable: `
|
||||
To pick up a sortable item, press space or enter.
|
||||
While dragging, use the ${orientation === "vertical" ? "up and down" : orientation === "horizontal" ? "left and right" : "arrow"} keys to move the item.
|
||||
Press space or enter again to drop the item in its new position, or press escape to cancel.
|
||||
`,
|
||||
}),
|
||||
[orientation]
|
||||
);
|
||||
|
||||
const contextValue = React.useMemo(
|
||||
() => ({
|
||||
id,
|
||||
items,
|
||||
modifiers: modifiers ?? config.modifiers,
|
||||
strategy: strategy ?? config.strategy,
|
||||
activeId,
|
||||
setActiveId,
|
||||
getItemValue,
|
||||
flatCursor,
|
||||
}),
|
||||
[id, items, modifiers, strategy, config.modifiers, config.strategy, activeId, getItemValue, flatCursor]
|
||||
);
|
||||
|
||||
return (
|
||||
<SortableRootContext.Provider value={contextValue as SortableRootContextValue<unknown>}>
|
||||
<DndContext
|
||||
collisionDetection={collisionDetection ?? config.collisionDetection}
|
||||
modifiers={modifiers ?? config.modifiers}
|
||||
sensors={sensors}
|
||||
{...sortableProps}
|
||||
id={id}
|
||||
onDragStart={onDragStart}
|
||||
onDragEnd={onDragEnd}
|
||||
onDragCancel={onDragCancel}
|
||||
accessibility={{
|
||||
announcements,
|
||||
screenReaderInstructions,
|
||||
...accessibility,
|
||||
}}
|
||||
/>
|
||||
</SortableRootContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
const SortableContentContext = React.createContext<boolean>(false);
|
||||
|
||||
interface SortableContentProps extends React.ComponentProps<"div"> {
|
||||
strategy?: SortableContextProps["strategy"];
|
||||
children: React.ReactNode;
|
||||
asChild?: boolean;
|
||||
withoutSlot?: boolean;
|
||||
}
|
||||
|
||||
function SortableContent(props: SortableContentProps) {
|
||||
const { strategy: strategyProp, asChild, withoutSlot, children, ref, ...contentProps } = props;
|
||||
|
||||
const context = useSortableContext(CONTENT_NAME);
|
||||
|
||||
const ContentPrimitive = asChild ? Slot : "div";
|
||||
|
||||
return (
|
||||
<SortableContentContext.Provider value={true}>
|
||||
<SortableContext items={context.items} strategy={strategyProp ?? context.strategy}>
|
||||
{withoutSlot ? (
|
||||
children
|
||||
) : (
|
||||
<ContentPrimitive data-slot="sortable-content" {...contentProps} ref={ref}>
|
||||
{children}
|
||||
</ContentPrimitive>
|
||||
)}
|
||||
</SortableContext>
|
||||
</SortableContentContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
interface SortableItemContextValue {
|
||||
id: string;
|
||||
attributes: DraggableAttributes;
|
||||
listeners: DraggableSyntheticListeners | undefined;
|
||||
setActivatorNodeRef: (node: HTMLElement | null) => void;
|
||||
isDragging?: boolean;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const SortableItemContext = React.createContext<SortableItemContextValue | null>(null);
|
||||
|
||||
function useSortableItemContext(consumerName: string) {
|
||||
const context = React.useContext(SortableItemContext);
|
||||
if (!context) {
|
||||
throw new Error(`\`${consumerName}\` must be used within \`${ITEM_NAME}\``);
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
interface SortableItemProps extends React.ComponentProps<"div"> {
|
||||
value: UniqueIdentifier;
|
||||
asHandle?: boolean;
|
||||
asChild?: boolean;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
function SortableItem(props: SortableItemProps) {
|
||||
const { value, style, asHandle, asChild, disabled, className, ref, ...itemProps } = props;
|
||||
|
||||
const inSortableContent = React.useContext(SortableContentContext);
|
||||
const inSortableOverlay = React.useContext(SortableOverlayContext);
|
||||
|
||||
if (!inSortableContent && !inSortableOverlay) {
|
||||
throw new Error(`\`${ITEM_NAME}\` must be used within \`${CONTENT_NAME}\` or \`${OVERLAY_NAME}\``);
|
||||
}
|
||||
|
||||
if (value === "") {
|
||||
throw new Error(`\`${ITEM_NAME}\` value cannot be an empty string`);
|
||||
}
|
||||
|
||||
const context = useSortableContext(ITEM_NAME);
|
||||
const id = React.useId();
|
||||
const { attributes, listeners, setNodeRef, setActivatorNodeRef, transform, transition, isDragging } = useSortable({
|
||||
id: value,
|
||||
disabled,
|
||||
});
|
||||
|
||||
const composedRef = useComposedRefs(ref, (node) => {
|
||||
if (disabled) return;
|
||||
setNodeRef(node);
|
||||
if (asHandle) setActivatorNodeRef(node);
|
||||
});
|
||||
|
||||
const composedStyle = React.useMemo<React.CSSProperties>(() => {
|
||||
return {
|
||||
transform: CSS.Translate.toString(transform),
|
||||
transition,
|
||||
...style,
|
||||
};
|
||||
}, [transform, transition, style]);
|
||||
|
||||
const itemContext = React.useMemo<SortableItemContextValue>(
|
||||
() => ({
|
||||
id,
|
||||
attributes,
|
||||
listeners,
|
||||
setActivatorNodeRef,
|
||||
isDragging,
|
||||
disabled,
|
||||
}),
|
||||
[id, attributes, listeners, setActivatorNodeRef, isDragging, disabled]
|
||||
);
|
||||
|
||||
const ItemPrimitive = asChild ? Slot : "div";
|
||||
|
||||
return (
|
||||
<SortableItemContext.Provider value={itemContext}>
|
||||
<ItemPrimitive
|
||||
id={id}
|
||||
data-disabled={disabled}
|
||||
data-dragging={isDragging ? "" : undefined}
|
||||
data-slot="sortable-item"
|
||||
{...itemProps}
|
||||
{...(asHandle && !disabled ? attributes : {})}
|
||||
{...(asHandle && !disabled ? listeners : {})}
|
||||
ref={composedRef}
|
||||
style={composedStyle}
|
||||
className={cn(
|
||||
"focus-visible:ring-ring focus-visible:ring-1 focus-visible:ring-offset-1 focus-visible:outline-hidden",
|
||||
{
|
||||
"touch-none select-none": asHandle,
|
||||
"cursor-default": context.flatCursor,
|
||||
"data-dragging:cursor-grabbing": !context.flatCursor,
|
||||
"cursor-grab": !isDragging && asHandle && !context.flatCursor,
|
||||
"opacity-50": isDragging,
|
||||
"pointer-events-none opacity-50": disabled,
|
||||
},
|
||||
className
|
||||
)}
|
||||
/>
|
||||
</SortableItemContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
interface SortableItemHandleProps extends React.ComponentProps<"button"> {
|
||||
asChild?: boolean;
|
||||
}
|
||||
|
||||
function SortableItemHandle(props: SortableItemHandleProps) {
|
||||
const { asChild, disabled, className, ref, ...itemHandleProps } = props;
|
||||
|
||||
const context = useSortableContext(ITEM_HANDLE_NAME);
|
||||
const itemContext = useSortableItemContext(ITEM_HANDLE_NAME);
|
||||
|
||||
const isDisabled = disabled ?? itemContext.disabled;
|
||||
|
||||
const composedRef = useComposedRefs(ref, (node) => {
|
||||
if (!isDisabled) return;
|
||||
itemContext.setActivatorNodeRef(node);
|
||||
});
|
||||
|
||||
const HandlePrimitive = asChild ? Slot : "button";
|
||||
|
||||
return (
|
||||
<HandlePrimitive
|
||||
type="button"
|
||||
aria-controls={itemContext.id}
|
||||
data-disabled={isDisabled}
|
||||
data-dragging={itemContext.isDragging ? "" : undefined}
|
||||
data-slot="sortable-item-handle"
|
||||
{...itemHandleProps}
|
||||
{...(isDisabled ? {} : itemContext.attributes)}
|
||||
{...(isDisabled ? {} : itemContext.listeners)}
|
||||
ref={composedRef}
|
||||
className={cn(
|
||||
"select-none disabled:pointer-events-none disabled:opacity-50",
|
||||
context.flatCursor ? "cursor-default" : "cursor-grab data-dragging:cursor-grabbing",
|
||||
className
|
||||
)}
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const SortableOverlayContext = React.createContext(false);
|
||||
|
||||
const dropAnimation: DropAnimation = {
|
||||
sideEffects: defaultDropAnimationSideEffects({
|
||||
styles: {
|
||||
active: {
|
||||
opacity: "0.4",
|
||||
},
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
interface SortableOverlayProps extends Omit<React.ComponentProps<typeof DragOverlay>, "children"> {
|
||||
container?: Element | DocumentFragment | null;
|
||||
children?: ((params: { value: UniqueIdentifier }) => React.ReactNode) | React.ReactNode;
|
||||
}
|
||||
|
||||
function SortableOverlay(props: SortableOverlayProps) {
|
||||
const { container: containerProp, children, ...overlayProps } = props;
|
||||
|
||||
const context = useSortableContext(OVERLAY_NAME);
|
||||
|
||||
const [mounted, setMounted] = React.useState(false);
|
||||
React.useLayoutEffect(() => setMounted(true), []);
|
||||
|
||||
const container = containerProp ?? (mounted ? globalThis.document?.body : null);
|
||||
|
||||
if (!container) return null;
|
||||
|
||||
return ReactDOM.createPortal(
|
||||
<DragOverlay
|
||||
dropAnimation={dropAnimation}
|
||||
modifiers={context.modifiers}
|
||||
className={cn(!context.flatCursor && "cursor-grabbing")}
|
||||
{...overlayProps}
|
||||
>
|
||||
<SortableOverlayContext.Provider value={true}>
|
||||
{context.activeId
|
||||
? typeof children === "function"
|
||||
? children({ value: context.activeId })
|
||||
: children
|
||||
: null}
|
||||
</SortableOverlayContext.Provider>
|
||||
</DragOverlay>,
|
||||
container
|
||||
);
|
||||
}
|
||||
|
||||
export { SortableRoot as Sortable, SortableContent, SortableItem, SortableItemHandle, SortableOverlay };
|
||||
48
src/components/UI/Tooltip.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
"use client";
|
||||
|
||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function TooltipProvider({ delayDuration = 0, ...props }: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
|
||||
return <TooltipPrimitive.Provider data-slot="tooltip-provider" delayDuration={delayDuration} {...props} />;
|
||||
}
|
||||
|
||||
function Tooltip({ ...props }: React.ComponentProps<typeof TooltipPrimitive.Root>) {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function TooltipTrigger({ ...props }: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
|
||||
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />;
|
||||
}
|
||||
|
||||
function TooltipContent({
|
||||
className,
|
||||
sideOffset = 0,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
|
||||
return (
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Content
|
||||
data-slot="tooltip-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 bg-text text-desktop-primary z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<TooltipPrimitive.Arrow className="bg-text fill-text z-50 size-2.5 translate-y-[calc(-50%-2px)] rotate-45 rounded-xs" />
|
||||
</TooltipPrimitive.Content>
|
||||
</TooltipPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
|
||||
export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger };
|
||||
2
src/data/protectedRoutes.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
// protected routes
|
||||
export const protectedRoutes = ["/panel", "/complaints/complaints-panel", "/app-complaints"];
|
||||
46
src/data/sidebarMenu.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
export const menuItems: Menu[] = [
|
||||
{ title: "تصاویر دوربین ها", href: "/road-cameras" },
|
||||
{ title: "شکایات", href: "/complaints" },
|
||||
{
|
||||
title: "اخبار",
|
||||
children: [
|
||||
{ title: "آخرین وضعیت راه های کشور", href: "/news/latest-roads-state" },
|
||||
{ title: "کارگاه های جاده ای", href: "/news/road-works" },
|
||||
{ title: "انسدادها", href: "/news/obstruction-list" },
|
||||
{ title: "محدودیت تردد", href: "/news/limitations" },
|
||||
{ title: "محدودیت اعلامی پلیس", href: "/news/police-traffic-restrictions" },
|
||||
{ title: "اطلاعیه های هواشناسی", href: "/news/road-weather" },
|
||||
],
|
||||
},
|
||||
// {
|
||||
// title: "راه و ترافیک",
|
||||
// children: [
|
||||
// { title: "طبقه بندی راه های کشور", href: "#" },
|
||||
// { title: "داده های تردد شمار", href: "#" },
|
||||
// ],
|
||||
// },
|
||||
];
|
||||
|
||||
export const mobileMenuItems: Menu[] = [
|
||||
{ title: "نقشه", href: "/" },
|
||||
{ title: "تصاویر دوربین ها", href: "/road-cameras" },
|
||||
{ title: "شکایات", href: "/complaints" },
|
||||
{
|
||||
title: "اخبار",
|
||||
children: [
|
||||
{ title: "آخرین وضعیت راه های کشور", href: "/news/latest-roads-state" },
|
||||
{ title: "کارگاه های جاده ای", href: "/news/road-works" },
|
||||
{ title: "انسدادها", href: "/news/obstruction-list" },
|
||||
{ title: "محدودیت تردد", href: "/news/limitations" },
|
||||
{ title: "محدودیت اعلامی پلیس", href: "/news/police-traffic-restrictions" },
|
||||
{ title: "اطلاعیه های هواشناسی", href: "/news/road-weather" },
|
||||
],
|
||||
},
|
||||
// {
|
||||
// title: "راه و ترافیک",
|
||||
// children: [
|
||||
// { title: "طبقه بندی راه های کشور", href: "#" },
|
||||
// { title: "داده های تردد شمار", href: "#" },
|
||||
// ],
|
||||
// },
|
||||
];
|
||||
104
src/hooks/useAuth.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { useAuthStore } from "@/stores/useAuthStore";
|
||||
import useUserStore from "@/stores/userStore";
|
||||
import { POST_LOGOUT, POST_OTP, POST_USER_PASS_LOGIN, POST_VERIFY_OTP_LOGIN_AND_SIGNUP } from "@/utils/apiRoutes";
|
||||
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import useRequest from "./useRequest";
|
||||
|
||||
export function useAuth() {
|
||||
const logoutClient = useUserStore((s) => s.logout);
|
||||
const setIsAuthenticating = useAuthStore((s) => s.setIsAuthenticating);
|
||||
const setTempPhoneNumber = useAuthStore((s) => s.setTempPhoneNumber);
|
||||
const setAuthPanelState = useAuthStore((s) => s.setAuthPanelState);
|
||||
const tempPhoneNumber = useAuthStore((s) => s.tempPhoneNumber);
|
||||
|
||||
const setToken = useUserStore((s) => s.setToken);
|
||||
const setUser = useUserStore((s) => s.changeUser);
|
||||
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const requestServer = useRequest({ auth: true, notification: true });
|
||||
|
||||
async function requestOtp(phone_number: string, aythType: "login" | "signup") {
|
||||
setIsAuthenticating(true);
|
||||
try {
|
||||
await requestServer(POST_OTP, "post", {
|
||||
data: { phone_number },
|
||||
});
|
||||
setTempPhoneNumber(phone_number);
|
||||
setAuthPanelState(aythType === "login" ? "loginOtp" : "signUpOtp");
|
||||
} catch (error) {
|
||||
console.error("OTP request failed:", error);
|
||||
} finally {
|
||||
setIsAuthenticating(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function VerifyOtpLoginAndSignup(otp: string) {
|
||||
if (!tempPhoneNumber) return;
|
||||
|
||||
setIsAuthenticating(true);
|
||||
try {
|
||||
const response = await requestServer(POST_VERIFY_OTP_LOGIN_AND_SIGNUP, "post", {
|
||||
data: {
|
||||
phone_number: tempPhoneNumber,
|
||||
otp,
|
||||
},
|
||||
});
|
||||
const { token, user } = response.data;
|
||||
setToken(token);
|
||||
setUser(user);
|
||||
|
||||
const backUrl = searchParams.get("back_url");
|
||||
if (backUrl) {
|
||||
router.replace(backUrl);
|
||||
} else {
|
||||
router.replace("/");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("OTP verification failed:", error);
|
||||
} finally {
|
||||
setIsAuthenticating(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function loginWithUserName(userName: string, password: string) {
|
||||
setIsAuthenticating(true);
|
||||
try {
|
||||
const response = await requestServer(POST_USER_PASS_LOGIN, "post", {
|
||||
data: {
|
||||
username: userName,
|
||||
password,
|
||||
},
|
||||
});
|
||||
const { token, user } = response.data;
|
||||
setToken(token);
|
||||
setUser(user);
|
||||
} catch (error) {
|
||||
console.error("Login failed:", error);
|
||||
} finally {
|
||||
setIsAuthenticating(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
setIsAuthenticating(true);
|
||||
try {
|
||||
await requestServer(POST_LOGOUT, "post");
|
||||
logoutClient();
|
||||
window.location.reload();
|
||||
} catch (error) {
|
||||
console.error("Logout failed:", error);
|
||||
} finally {
|
||||
setIsAuthenticating(false);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
logout,
|
||||
requestOtp,
|
||||
signIn: VerifyOtpLoginAndSignup,
|
||||
loginWithUserName,
|
||||
};
|
||||
}
|
||||
22
src/hooks/useDebouncedCallback.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
|
||||
export function useDebouncedCallback<T extends (...args: never[]) => void>(callback: T, delay: number) {
|
||||
const timer = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
const debounced = useCallback(
|
||||
(...args: Parameters<T>) => {
|
||||
if (timer.current) clearTimeout(timer.current);
|
||||
timer.current = setTimeout(() => callback(...args), delay);
|
||||
},
|
||||
[callback, delay]
|
||||
);
|
||||
|
||||
const cancel = useCallback(() => {
|
||||
if (timer.current) clearTimeout(timer.current);
|
||||
timer.current = null;
|
||||
}, []);
|
||||
|
||||
useEffect(() => cancel, [cancel]);
|
||||
|
||||
return { debounced, cancel };
|
||||
}
|
||||
17
src/hooks/useDevice.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export default function useDevice() {
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const checkIsMobile = () => {
|
||||
setIsMobile(window.innerWidth < 768);
|
||||
};
|
||||
|
||||
checkIsMobile();
|
||||
window.addEventListener("resize", checkIsMobile);
|
||||
return () => window.removeEventListener("resize", checkIsMobile);
|
||||
}, []);
|
||||
|
||||
return isMobile;
|
||||
}
|
||||
92
src/hooks/useRequest.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
"use client";
|
||||
import axios from "axios";
|
||||
import { useTranslations } from "next-intl";
|
||||
import useUserStore from "@/stores/userStore";
|
||||
import ToastStore from "@/stores/useToastStore";
|
||||
import { Notifications } from "@/notifications";
|
||||
|
||||
export interface RequestOptions {
|
||||
auth?: boolean;
|
||||
data?: Record<string, string> | FormData;
|
||||
requestOptions?: {
|
||||
headers?: Record<string, string>;
|
||||
signal?: AbortSignal;
|
||||
[key: string]: any;
|
||||
};
|
||||
notification?: boolean;
|
||||
pending?: boolean;
|
||||
success?: {
|
||||
notification: {
|
||||
show: boolean;
|
||||
};
|
||||
};
|
||||
failed?: {
|
||||
notification: {
|
||||
show: boolean;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
const defaultOptions: RequestOptions = {
|
||||
auth: false,
|
||||
data: {},
|
||||
requestOptions: {
|
||||
headers: {},
|
||||
},
|
||||
notification: true,
|
||||
pending: true,
|
||||
success: {
|
||||
notification: {
|
||||
show: true,
|
||||
},
|
||||
},
|
||||
failed: {
|
||||
notification: {
|
||||
show: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const useRequest = (initOptions: RequestOptions) => {
|
||||
const t = useTranslations();
|
||||
const { token, clearToken } = useUserStore();
|
||||
const { pushToastList, dismissToastList } = ToastStore();
|
||||
|
||||
let _options = { ...defaultOptions, ...initOptions };
|
||||
|
||||
function requestServer(url: string, method: string = "get", options?: RequestOptions) {
|
||||
_options = { ..._options, ...options };
|
||||
|
||||
if (_options.auth) {
|
||||
const isFormData = _options.data instanceof FormData;
|
||||
_options = {
|
||||
..._options,
|
||||
requestOptions: {
|
||||
...(_options.requestOptions || {}),
|
||||
headers: {
|
||||
...(_options.requestOptions?.headers || {}),
|
||||
...(isFormData ? {} : { "Content-Type": "application/json" }),
|
||||
authorization: `Bearer ${token}`,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return Notifications(
|
||||
axios({
|
||||
url,
|
||||
method,
|
||||
data: _options.data,
|
||||
..._options.requestOptions,
|
||||
}),
|
||||
{
|
||||
t,
|
||||
handlers: { pushToastList, dismissToastList, clearToken },
|
||||
options: _options,
|
||||
}
|
||||
);
|
||||
}
|
||||
return requestServer;
|
||||
};
|
||||
|
||||
export default useRequest;
|
||||
12
src/i18n/request.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { cookies } from "next/headers";
|
||||
import { getRequestConfig } from "next-intl/server";
|
||||
|
||||
export default getRequestConfig(async () => {
|
||||
const cookieStore = await cookies();
|
||||
const locale = cookieStore.get("language")?.value || "fa";
|
||||
|
||||
return {
|
||||
locale,
|
||||
messages: (await import(`../../messages/${locale}.json`)).default,
|
||||
};
|
||||
});
|
||||
66
src/lib/compose-refs.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* @see https://github.com/radix-ui/primitives/blob/main/packages/react/compose-refs/src/compose-refs.tsx
|
||||
*/
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
type PossibleRef<T> = React.Ref<T> | undefined;
|
||||
|
||||
/**
|
||||
* Set a given ref to a given value
|
||||
* This utility takes care of different types of refs: callback refs and RefObject(s)
|
||||
*/
|
||||
function setRef<T>(ref: PossibleRef<T>, value: T) {
|
||||
if (typeof ref === "function") {
|
||||
return ref(value);
|
||||
}
|
||||
|
||||
if (ref !== null && ref !== undefined) {
|
||||
ref.current = value;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A utility to compose multiple refs together
|
||||
* Accepts callback refs and RefObject(s)
|
||||
*/
|
||||
function composeRefs<T>(...refs: PossibleRef<T>[]): React.RefCallback<T> {
|
||||
return (node) => {
|
||||
let hasCleanup = false;
|
||||
const cleanups = refs.map((ref) => {
|
||||
const cleanup = setRef(ref, node);
|
||||
if (!hasCleanup && typeof cleanup === "function") {
|
||||
hasCleanup = true;
|
||||
}
|
||||
return cleanup;
|
||||
});
|
||||
|
||||
// React <19 will log an error to the console if a callback ref returns a
|
||||
// value. We don't use ref cleanups internally so this will only happen if a
|
||||
// user's ref callback returns a value, which we only expect if they are
|
||||
// using the cleanup functionality added in React 19.
|
||||
if (hasCleanup) {
|
||||
return () => {
|
||||
for (let i = 0; i < cleanups.length; i++) {
|
||||
const cleanup = cleanups[i];
|
||||
if (typeof cleanup === "function") {
|
||||
cleanup();
|
||||
} else {
|
||||
setRef(refs[i], null);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A custom hook that composes multiple refs
|
||||
* Accepts callback refs and RefObject(s)
|
||||
*/
|
||||
function useComposedRefs<T>(...refs: PossibleRef<T>[]): React.RefCallback<T> {
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: we want to memoize by all values
|
||||
return React.useCallback(composeRefs(...refs), refs);
|
||||
}
|
||||
|
||||
export { composeRefs, useComposedRefs };
|
||||
6
src/lib/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
133
src/notifications/index.tsx
Normal file
@@ -0,0 +1,133 @@
|
||||
import toast from "react-hot-toast";
|
||||
import { AxiosResponse } from "axios";
|
||||
|
||||
type ToastType = "pending" | "error" | "warning" | "success";
|
||||
type TranslationFunction = (key: string) => string;
|
||||
|
||||
interface ToastHandlers {
|
||||
pushToastList: (type: ToastType, id: string) => void;
|
||||
dismissToastList: (types: ToastType[]) => void;
|
||||
clearToken: () => void;
|
||||
}
|
||||
|
||||
interface RequestOptions {
|
||||
auth?: boolean;
|
||||
data?: Record<string, string> | FormData;
|
||||
requestOptions?: {
|
||||
headers?: Record<string, string>;
|
||||
signal?: AbortSignal;
|
||||
[key: string]: any;
|
||||
};
|
||||
notification?: boolean;
|
||||
pending?: boolean;
|
||||
success?: {
|
||||
notification: {
|
||||
show: boolean;
|
||||
};
|
||||
};
|
||||
failed?: {
|
||||
notification: {
|
||||
show: boolean;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
interface ToastPromiseOptions {
|
||||
t: TranslationFunction;
|
||||
handlers: ToastHandlers;
|
||||
options: RequestOptions;
|
||||
}
|
||||
|
||||
export const Notifications = (promise: Promise<AxiosResponse>, { t, handlers, options }: ToastPromiseOptions) => {
|
||||
const { pushToastList, dismissToastList, clearToken } = handlers;
|
||||
|
||||
const showNotification = options.notification ?? true;
|
||||
const showPending = options.pending ?? true;
|
||||
const showSuccess = options.success?.notification?.show ?? true;
|
||||
const showFailed = options.failed?.notification?.show ?? true;
|
||||
|
||||
if (!showNotification) return promise;
|
||||
|
||||
return toast.promise(
|
||||
promise,
|
||||
{
|
||||
loading: showPending ? t("Notifications.pending") : "",
|
||||
success: (response: AxiosResponse) => {
|
||||
if (showSuccess) {
|
||||
dismissToastList(["pending", "warning", "error", "success"]);
|
||||
pushToastList("success", String(response.status));
|
||||
}
|
||||
return t("Notifications.success");
|
||||
},
|
||||
error: (error: any) => {
|
||||
if (showFailed) {
|
||||
dismissToastList(["pending", "warning", "error", "success"]);
|
||||
}
|
||||
|
||||
if (!showFailed) return t("Notifications.error");
|
||||
|
||||
let message = t("Notifications.error");
|
||||
|
||||
if (error.response) {
|
||||
const status = error.response.status;
|
||||
const responseData = error.response.data;
|
||||
|
||||
// build default message with status code
|
||||
message = `${status} - ${t("Notifications.error")}`;
|
||||
|
||||
if (status >= 500 && status <= 599) {
|
||||
pushToastList("warning", String(status));
|
||||
} else if (status >= 400 && status <= 499) {
|
||||
switch (status) {
|
||||
case 401:
|
||||
clearToken();
|
||||
pushToastList("error", String(status));
|
||||
break;
|
||||
case 422:
|
||||
// append validation messages
|
||||
if (responseData?.message) {
|
||||
if (Array.isArray(responseData.message)) {
|
||||
message = responseData.message.join(", ");
|
||||
} else {
|
||||
message = responseData.message;
|
||||
}
|
||||
}
|
||||
if (responseData?.errors) {
|
||||
const errs: string[] = [];
|
||||
Object.keys(responseData.errors).forEach((key) => {
|
||||
const msg = responseData.errors[key]?.[0];
|
||||
if (msg) errs.push(msg);
|
||||
});
|
||||
if (errs.length) {
|
||||
message = errs.join(", ");
|
||||
}
|
||||
}
|
||||
break;
|
||||
case 429:
|
||||
message = `${status} - Too many requests`;
|
||||
pushToastList("error", String(status));
|
||||
break;
|
||||
default:
|
||||
pushToastList("error", String(status));
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
pushToastList("error", t("Notifications.error"));
|
||||
}
|
||||
} else if (error.request) {
|
||||
message = t("Notifications.error") + " (No response)";
|
||||
pushToastList("error", message);
|
||||
} else {
|
||||
message = t("Notifications.error") + " (Request failed)";
|
||||
pushToastList("error", message);
|
||||
}
|
||||
|
||||
return message;
|
||||
},
|
||||
},
|
||||
{
|
||||
position: "top-center",
|
||||
duration: 4000,
|
||||
}
|
||||
);
|
||||
};
|
||||
13
src/providers/AppShell.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
23
src/providers/ClientAppProvider.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
25
src/providers/DeviceProvider.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useState, useEffectEvent } from "react";
|
||||
import { useDeviceStore } from "@/stores/useDeviceStore";
|
||||
import useDevice from "@/hooks/useDevice";
|
||||
|
||||
export default function DeviceProvider({ children }: { children: React.ReactNode }) {
|
||||
const isMobileDevice = useDevice();
|
||||
const setIsMobile = useDeviceStore((state) => state.setIsMobile);
|
||||
|
||||
const [ready, setReady] = useState(false);
|
||||
|
||||
const syncDevice = useEffectEvent((isMobile: boolean) => {
|
||||
setIsMobile(isMobile);
|
||||
setReady(true);
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
syncDevice(isMobileDevice);
|
||||
}, [isMobileDevice]);
|
||||
|
||||
if (!ready) return null;
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||