theme provider
@@ -25,6 +25,7 @@
|
||||
"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",
|
||||
|
||||
12
pnpm-lock.yaml
generated
@@ -52,6 +52,9 @@ importers:
|
||||
framer-motion:
|
||||
specifier: ^12.9.2
|
||||
version: 12.23.26(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
js-cookie:
|
||||
specifier: ^3.0.5
|
||||
version: 3.0.5
|
||||
maplibre-gl:
|
||||
specifier: ^5.4.0
|
||||
version: 5.15.0
|
||||
@@ -4948,6 +4951,13 @@ packages:
|
||||
}
|
||||
hasBin: true
|
||||
|
||||
js-cookie@3.0.5:
|
||||
resolution:
|
||||
{
|
||||
integrity: sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==,
|
||||
}
|
||||
engines: { node: ">=14" }
|
||||
|
||||
js-tokens@4.0.0:
|
||||
resolution:
|
||||
{
|
||||
@@ -10352,6 +10362,8 @@ snapshots:
|
||||
|
||||
jiti@2.6.1: {}
|
||||
|
||||
js-cookie@3.0.5: {}
|
||||
|
||||
js-tokens@4.0.0: {}
|
||||
|
||||
js-yaml@4.1.1:
|
||||
|
||||
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 |
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 4.5 KiB |
@@ -1,8 +1,15 @@
|
||||
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";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: {
|
||||
@@ -27,11 +34,7 @@ export const viewport = {
|
||||
maximumScale: 1,
|
||||
};
|
||||
|
||||
export default async function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
export default async function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
const locale = await getLocale();
|
||||
const messages = await getMessages({ locale });
|
||||
return (
|
||||
@@ -39,7 +42,15 @@ export default async function RootLayout({
|
||||
<body>
|
||||
<NextTopLoader color="#16a795" />
|
||||
<NextIntlClientProvider locale={locale} messages={messages}>
|
||||
{children}
|
||||
<ClientAppProvider>
|
||||
<AppShell>
|
||||
<UserInitializer />
|
||||
{children}
|
||||
<Modal />
|
||||
<ToastProvider />
|
||||
<Sidebar />
|
||||
</AppShell>
|
||||
</ClientAppProvider>
|
||||
</NextIntlClientProvider>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,2 +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";
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,40 +1,23 @@
|
||||
"use client";
|
||||
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { ThemeProvider } from "next-themes";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useEffectEvent, useState } from "react";
|
||||
|
||||
export function ClientAppProvider({ children }: { children: React.ReactNode }) {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
// wait for hydration
|
||||
useEffect(() => {
|
||||
const syncDevice = useEffectEvent(() => {
|
||||
setMounted(true);
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
syncDevice();
|
||||
}, []);
|
||||
|
||||
if (!mounted) return null;
|
||||
|
||||
return (
|
||||
<ThemeProvider attribute="data-theme" defaultTheme="dark" enableSystem>
|
||||
<AnimatePresence mode="wait">
|
||||
{!mounted ? (
|
||||
<motion.div
|
||||
key="splash"
|
||||
className="bg-map-bg flex h-dvh w-dvw items-center justify-center"
|
||||
initial={{ opacity: 1 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
/>
|
||||
) : (
|
||||
<motion.div
|
||||
key="app"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.3, ease: "easeOut" }}
|
||||
className="bg-map-bg"
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
{children}
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
13
src/stores/SidebarStore.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { create } from "zustand";
|
||||
|
||||
type SidebarState = {
|
||||
isOpen: boolean;
|
||||
toggle: () => void;
|
||||
close: () => void;
|
||||
};
|
||||
|
||||
export const useSidebarStore = create<SidebarState>((set) => ({
|
||||
isOpen: false,
|
||||
toggle: () => set((state) => ({ isOpen: !state.isOpen })),
|
||||
close: () => set({ isOpen: false }),
|
||||
}));
|
||||
21
src/stores/useMapLayersStore.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { create } from "zustand";
|
||||
import { persist } from "zustand/middleware";
|
||||
|
||||
export type LayerType = "dark" | "bright";
|
||||
|
||||
type MapLayersStore = {
|
||||
activeLayer: LayerType;
|
||||
setActiveLayer: (layer: LayerType) => void;
|
||||
};
|
||||
|
||||
export const useMapLayersStore = create<MapLayersStore>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
activeLayer: "dark",
|
||||
setActiveLayer: (layer) => set({ activeLayer: layer }),
|
||||
}),
|
||||
{
|
||||
name: "map-theme", // نام کلید در localStorage
|
||||
}
|
||||
)
|
||||
);
|
||||
@@ -19,7 +19,8 @@
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
"@/*": ["./src/*"],
|
||||
"&/*": ["./public/*"]
|
||||
}
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", ".next/dev/types/**/*.ts", "**/*.mts"],
|
||||
|
||||