diff --git a/package.json b/package.json index 155a33f..c4689a0 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 385d6dc..d51e7b1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: diff --git a/public/icons/avatar-icon.svg b/public/icons/avatar-icon.svg new file mode 100644 index 0000000..c0b550e --- /dev/null +++ b/public/icons/avatar-icon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/icons/check-icon.svg b/public/icons/check-icon.svg new file mode 100644 index 0000000..e9dc75a --- /dev/null +++ b/public/icons/check-icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/icons/chevron-icon.svg b/public/icons/chevron-icon.svg new file mode 100644 index 0000000..7914948 --- /dev/null +++ b/public/icons/chevron-icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/icons/close-icon.svg b/public/icons/close-icon.svg new file mode 100644 index 0000000..475466c --- /dev/null +++ b/public/icons/close-icon.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/public/icons/dark-mode-icon.svg b/public/icons/dark-mode-icon.svg new file mode 100644 index 0000000..bcab3f0 --- /dev/null +++ b/public/icons/dark-mode-icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/icons/google-maps.svg b/public/icons/google-maps.svg new file mode 100644 index 0000000..492fe52 --- /dev/null +++ b/public/icons/google-maps.svg @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/icons/light-mode-icon.svg b/public/icons/light-mode-icon.svg new file mode 100644 index 0000000..aef07df --- /dev/null +++ b/public/icons/light-mode-icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/icons/logout.svg b/public/icons/logout.svg new file mode 100644 index 0000000..dc10883 --- /dev/null +++ b/public/icons/logout.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/src/app/favicon.ico b/src/app/favicon.ico index 4de7d12..5fb9cfc 100644 Binary files a/src/app/favicon.ico and b/src/app/favicon.ico differ diff --git a/src/app/layout.tsx b/src/app/layout.tsx index e5928a1..a0c4f9f 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -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({ - {children} + + + + {children} + + + + + diff --git a/src/assets/index.ts b/src/assets/index.ts index 0171d4c..9afd110 100644 --- a/src/assets/index.ts +++ b/src/assets/index.ts @@ -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"; diff --git a/src/components/Modals/LogoutModal.tsx b/src/components/Modals/LogoutModal.tsx new file mode 100644 index 0000000..50b9abb --- /dev/null +++ b/src/components/Modals/LogoutModal.tsx @@ -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 ( +
+
+

{t("logout")}

+

{t("confirmLogout")}

+
+ +
+ + +
+
+ ); +}; + +export default LogoutModal; diff --git a/src/components/Modals/MapLauncherModal.tsx b/src/components/Modals/MapLauncherModal.tsx new file mode 100644 index 0000000..3a68aa0 --- /dev/null +++ b/src/components/Modals/MapLauncherModal.tsx @@ -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( +
+
+
+

+ به زودی می‌توانید از اپلیکیشن 141 برای مسیریابی استفاده کنید. +

+
+ +
+ +
+
+
+ ); + } + + 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: , + label: "Google Maps", + onClick: openGoogleMaps, + }, + { + id: "141", + icon: 141 Map, + label: "141", + onClick: open141Map, + }, + { + id: "neshan", + icon: neshan Map, + label: "نشان", + onClick: openNeshanMap, + }, + ]; + + return ( +
+
+ +
+ +
+ {launchers.map((item) => ( + + ))} +
+
+ ); +} diff --git a/src/components/Modals/RedirectToSignInModal.tsx b/src/components/Modals/RedirectToSignInModal.tsx new file mode 100644 index 0000000..02e2fb2 --- /dev/null +++ b/src/components/Modals/RedirectToSignInModal.tsx @@ -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 ( +
+
+

{t("signInModalTitle")}

+

{t("signInModalDescription")}

+
+ +
+ + +
+
+ ); +} diff --git a/src/components/SideBar/SideBarContent/MenuItem.tsx b/src/components/SideBar/SideBarContent/MenuItem.tsx new file mode 100644 index 0000000..a538552 --- /dev/null +++ b/src/components/SideBar/SideBarContent/MenuItem.tsx @@ -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 ( + + + {icon} +

{label}

+
+ + ); +} diff --git a/src/components/SideBar/SideBarContent/SubMenu.tsx b/src/components/SideBar/SideBarContent/SubMenu.tsx new file mode 100644 index 0000000..73f8cc5 --- /dev/null +++ b/src/components/SideBar/SideBarContent/SubMenu.tsx @@ -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 ( +
+ + + {isOpen && ( + + {children} + + )} + +
+ ); +} diff --git a/src/components/SideBar/SideBarContent/index.tsx b/src/components/SideBar/SideBarContent/index.tsx new file mode 100644 index 0000000..4a086dc --- /dev/null +++ b/src/components/SideBar/SideBarContent/index.tsx @@ -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 ( +
+ {items.map((item, index) => ( + + {item.children ? ( + <> + + + + + + + + + ) : ( + + )} + {level === 0 && } + + ))} +
+ ); +} diff --git a/src/components/SideBar/index.tsx b/src/components/SideBar/index.tsx new file mode 100644 index 0000000..1c8d939 --- /dev/null +++ b/src/components/SideBar/index.tsx @@ -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 ( + + {isOpen && ( + +
+ 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 */} + + + + + + {/* Nav Items */} + + + {/* Footer */} + +
+ + {t("policy")} +
+
+
+
+ )} +
+ ); +}; diff --git a/src/components/ThemeToggle/index.tsx b/src/components/ThemeToggle/index.tsx new file mode 100644 index 0000000..b393f68 --- /dev/null +++ b/src/components/ThemeToggle/index.tsx @@ -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 ( + + + + ); +} diff --git a/src/providers/AppShell.tsx b/src/providers/AppShell.tsx new file mode 100644 index 0000000..951c74f --- /dev/null +++ b/src/providers/AppShell.tsx @@ -0,0 +1,13 @@ +"use client"; + +import { AnimatePresence, motion } from "framer-motion"; + +export function AppShell({ children }: { children: React.ReactNode }) { + return ( + + + {children} + + + ); +} diff --git a/src/providers/ClientAppProvider.tsx b/src/providers/ClientAppProvider.tsx index 62096d0..17c1317 100644 --- a/src/providers/ClientAppProvider.tsx +++ b/src/providers/ClientAppProvider.tsx @@ -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 ( - - {!mounted ? ( - - ) : ( - - {children} - - )} - + {children} ); } diff --git a/src/stores/SidebarStore.ts b/src/stores/SidebarStore.ts new file mode 100644 index 0000000..ab8545c --- /dev/null +++ b/src/stores/SidebarStore.ts @@ -0,0 +1,13 @@ +import { create } from "zustand"; + +type SidebarState = { + isOpen: boolean; + toggle: () => void; + close: () => void; +}; + +export const useSidebarStore = create((set) => ({ + isOpen: false, + toggle: () => set((state) => ({ isOpen: !state.isOpen })), + close: () => set({ isOpen: false }), +})); diff --git a/src/stores/useMapLayersStore.ts b/src/stores/useMapLayersStore.ts new file mode 100644 index 0000000..efe16ea --- /dev/null +++ b/src/stores/useMapLayersStore.ts @@ -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()( + persist( + (set) => ({ + activeLayer: "dark", + setActiveLayer: (layer) => set({ activeLayer: layer }), + }), + { + name: "map-theme", // نام کلید در localStorage + } + ) +); diff --git a/tsconfig.json b/tsconfig.json index 516d777..81a75ed 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -19,7 +19,8 @@ } ], "paths": { - "@/*": ["./src/*"] + "@/*": ["./src/*"], + "&/*": ["./public/*"] } }, "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", ".next/dev/types/**/*.ts", "**/*.mts"],