theme provider

This commit is contained in:
2025-12-24 11:49:17 +03:30
parent c5872fb762
commit 6a644b9a4e
26 changed files with 637 additions and 33 deletions

View File

@@ -25,6 +25,7 @@
"axios": "^1.10.0", "axios": "^1.10.0",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"framer-motion": "^12.9.2", "framer-motion": "^12.9.2",
"js-cookie": "^3.0.5",
"maplibre-gl": "^5.4.0", "maplibre-gl": "^5.4.0",
"next": "16.1.1", "next": "16.1.1",
"next-intl": "^4.6.1", "next-intl": "^4.6.1",

12
pnpm-lock.yaml generated
View File

@@ -52,6 +52,9 @@ importers:
framer-motion: framer-motion:
specifier: ^12.9.2 specifier: ^12.9.2
version: 12.23.26(react-dom@19.2.3(react@19.2.3))(react@19.2.3) 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: maplibre-gl:
specifier: ^5.4.0 specifier: ^5.4.0
version: 5.15.0 version: 5.15.0
@@ -4948,6 +4951,13 @@ packages:
} }
hasBin: true hasBin: true
js-cookie@3.0.5:
resolution:
{
integrity: sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==,
}
engines: { node: ">=14" }
js-tokens@4.0.0: js-tokens@4.0.0:
resolution: resolution:
{ {
@@ -10352,6 +10362,8 @@ snapshots:
jiti@2.6.1: {} jiti@2.6.1: {}
js-cookie@3.0.5: {}
js-tokens@4.0.0: {} js-tokens@4.0.0: {}
js-yaml@4.1.1: js-yaml@4.1.1:

View File

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

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

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

After

Width:  |  Height:  |  Size: 185 B

View File

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

After

Width:  |  Height:  |  Size: 176 B

View File

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

After

Width:  |  Height:  |  Size: 584 B

View File

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

After

Width:  |  Height:  |  Size: 1020 B

View File

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

After

Width:  |  Height:  |  Size: 3.6 KiB

View File

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

After

Width:  |  Height:  |  Size: 356 B

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

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

After

Width:  |  Height:  |  Size: 348 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 4.5 KiB

View File

@@ -1,8 +1,15 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import "&/fonts.css";
import "@/styles/globals.css"; import "@/styles/globals.css";
import { NextIntlClientProvider } from "next-intl"; import { NextIntlClientProvider } from "next-intl";
import { getLocale, getMessages } from "next-intl/server"; import { getLocale, getMessages } from "next-intl/server";
import NextTopLoader from "nextjs-toploader"; 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 = { export const metadata: Metadata = {
title: { title: {
@@ -27,11 +34,7 @@ export const viewport = {
maximumScale: 1, maximumScale: 1,
}; };
export default async function RootLayout({ export default async function RootLayout({ children }: { children: React.ReactNode }) {
children,
}: Readonly<{
children: React.ReactNode;
}>) {
const locale = await getLocale(); const locale = await getLocale();
const messages = await getMessages({ locale }); const messages = await getMessages({ locale });
return ( return (
@@ -39,7 +42,15 @@ export default async function RootLayout({
<body> <body>
<NextTopLoader color="#16a795" /> <NextTopLoader color="#16a795" />
<NextIntlClientProvider locale={locale} messages={messages}> <NextIntlClientProvider locale={locale} messages={messages}>
{children} <ClientAppProvider>
<AppShell>
<UserInitializer />
{children}
<Modal />
<ToastProvider />
<Sidebar />
</AppShell>
</ClientAppProvider>
</NextIntlClientProvider> </NextIntlClientProvider>
</body> </body>
</html> </html>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,40 +1,23 @@
"use client"; "use client";
import { AnimatePresence, motion } from "framer-motion";
import { ThemeProvider } from "next-themes"; import { ThemeProvider } from "next-themes";
import { useEffect, useState } from "react"; import { useEffect, useEffectEvent, useState } from "react";
export function ClientAppProvider({ children }: { children: React.ReactNode }) { export function ClientAppProvider({ children }: { children: React.ReactNode }) {
const [mounted, setMounted] = useState(false); const [mounted, setMounted] = useState(false);
const syncDevice = useEffectEvent(() => {
// wait for hydration
useEffect(() => {
setMounted(true); setMounted(true);
});
useEffect(() => {
syncDevice();
}, []); }, []);
if (!mounted) return null;
return ( return (
<ThemeProvider attribute="data-theme" defaultTheme="dark" enableSystem> <ThemeProvider attribute="data-theme" defaultTheme="dark" enableSystem>
<AnimatePresence mode="wait"> {children}
{!mounted ? (
<motion.div
key="splash"
className="bg-map-bg flex h-dvh w-dvw items-center justify-center"
initial={{ opacity: 1 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
/>
) : (
<motion.div
key="app"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.3, ease: "easeOut" }}
className="bg-map-bg"
>
{children}
</motion.div>
)}
</AnimatePresence>
</ThemeProvider> </ThemeProvider>
); );
} }

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

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

View File

@@ -19,7 +19,8 @@
} }
], ],
"paths": { "paths": {
"@/*": ["./src/*"] "@/*": ["./src/*"],
"&/*": ["./public/*"]
} }
}, },
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", ".next/dev/types/**/*.ts", "**/*.mts"], "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", ".next/dev/types/**/*.ts", "**/*.mts"],