install packages

This commit is contained in:
2025-12-24 11:20:50 +03:30
parent 87b5f80f1f
commit c5872fb762
91 changed files with 24445 additions and 4271 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

View File

@@ -1,26 +0,0 @@
@import "tailwindcss";
:root {
--background: #ffffff;
--foreground: #171717;
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
}
body {
background: var(--background);
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
}

View File

@@ -1,34 +1,47 @@
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 "@/styles/globals.css";
import { NextIntlClientProvider } from "next-intl";
import { getLocale, getMessages } from "next-intl/server";
import NextTopLoader from "nextjs-toploader";
export const metadata: Metadata = {
title: {
template: "%s | سالن رو",
default: "سالن رو",
},
description: "وب سایت سالن رو",
authors: {
name: "شرکت میرفران تک",
url: "https://menulita.ir",
},
manifest: "/manifest.json",
icons: {
icon: "/logo/192px.png",
apple: "/logo/512px.png",
},
};
export const viewport = {
width: "device-width",
initialScale: 1,
maximumScale: 1,
};
export default async function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
const locale = await getLocale();
const messages = await getMessages({ locale });
return (
<html lang={locale} dir="rtl">
<body>
<NextTopLoader color="#16a795" />
<NextIntlClientProvider locale={locale} messages={messages}>
{children}
</NextIntlClientProvider>
</body>
</html>
);
}

View File

@@ -1,65 +1,6 @@
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>
);
}
import { useTranslations } from "next-intl";
export default function Home() {
const t = useTranslations("HomePage");
return <h1>{t("title")}</h1>;
}

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

@@ -0,0 +1,2 @@
export { default as CheckIcon } from "&/icons/check-icon.svg";
export { default as ChevronIcon } from "&/icons/chevron-icon.svg";

View File

@@ -0,0 +1,26 @@
{
"labelInvalidField": "این فیلد شامل فایل‌های نامعتبر است",
"labelFileWaitingForSize": "در انتظار بررسی سایز فایل",
"labelFileSizeNotAvailable": "اندازه فایل {fileName} موجود نیست",
"labelFileLoading": "در حال بارگذاری",
"labelFileLoadError": "خطا هنگام بارگذاری",
"labelFileProcessing": "در حال آپلود",
"labelFileProcessingComplete": "آپلود کامل شد",
"labelFileProcessingAborted": "آپلود لغو شد",
"labelFileProcessingError": "خطا هنگام آپلود",
"labelTapToCancel": "برای لغو کلیک کنید",
"labelTapToRetry": "برای تلاش دوباره کلیک کنید",
"labelTapToUndo": "برای بازگشت کلیک کنید",
"labelButtonRemoveItem": "حذف",
"labelButtonAbortItemLoad": "لغو",
"labelButtonRetryItemLoad": "تلاش دوباره",
"labelButtonAbortItemProcessing": "لغو",
"labelButtonRetryItemProcessing": "تلاش دوباره",
"labelButtonProcessItem": "آپلود",
"labelMaxFileSizeExceeded": "حجم فایل بیش از حد مجاز است",
"labelMaxFileSize": "حداکثر حجم مجاز: {filesize}",
"labelFileTypeNotAllowed": "نوع فایل {fileName} مجاز نیست",
"labelFileSizeMegabytes": "مگابایت",
"labelFileSizeKilobytes": "کیلوبایت",
"labelFileSizeGigabytes": "گیگابایت"
}

View File

@@ -0,0 +1,82 @@
"use client";
import { useBottomSheetStore } from "@/stores/useBottomSheetStore";
import { AnimatePresence, motion } from "framer-motion";
import { useEffect } from "react";
import { createPortal } from "react-dom";
const BottomSheet = () => {
const { isOpen, content, options, closeBottomSheet } = useBottomSheetStore();
const interactive = options?.interactiveBackground ?? false;
const viewportHeight = typeof window !== "undefined" ? window.innerHeight : 0;
// Escape key (only active for modal mode)
useEffect(() => {
if (!isOpen || interactive) return;
const handleEscape = (e: KeyboardEvent) => {
if (e.key === "Escape") closeBottomSheet();
};
document.addEventListener("keydown", handleEscape);
document.body.style.overflow = "hidden";
return () => {
document.removeEventListener("keydown", handleEscape);
document.body.style.overflow = "unset";
};
}, [isOpen, interactive, closeBottomSheet]);
return createPortal(
<AnimatePresence>
{isOpen && (
<>
{/* Non-interactive background mode (modal) */}
{!interactive && (
<motion.div
key="modal-overlay"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.25 }}
className="fixed inset-0 z-50 bg-black/50"
onClick={closeBottomSheet}
/>
)}
{/* The sheet (works in both modes) */}
<motion.div
key="sheet"
initial={{ y: viewportHeight }}
animate={{ y: 0 }}
exit={{ y: viewportHeight }}
transition={{ type: "spring", damping: 30, stiffness: 300 }}
drag="y"
dragConstraints={{ top: 0, bottom: 0 }}
dragElastic={{ top: 0, bottom: 0.25 }}
onDragEnd={(e, info) => {
if (info.offset.y > 100 || info.velocity.y > 500) {
closeBottomSheet();
}
}}
className="bg-pwa-primary fixed inset-x-0 bottom-0 z-[60] mx-auto w-full max-w-md rounded-t-xl shadow-lg"
onClick={(e) => e.stopPropagation()}
>
<div className="flex justify-center py-2">
<div className="h-1 w-12 rounded-full bg-gray-400" />
</div>
<div className="overflow-y-auto p-4" style={{ maxHeight: "90vh" }} dir="rtl">
{content}
</div>
</motion.div>
</>
)}
</AnimatePresence>,
document.body
);
};
export default BottomSheet;

View File

@@ -0,0 +1,161 @@
"use client";
import { cn } from "@/lib/utils";
import * as React from "react";
import { Chevron, DayButton, getDefaultClassNames } from "react-day-picker";
import { DayPicker } from "react-day-picker/persian";
interface CalendarHijriProps {
selected?: Date;
onSelect?: (date: Date | undefined) => void;
defaultMonth?: Date;
className?: string;
}
export function CalendarHijri({ selected, onSelect, defaultMonth, className }: CalendarHijriProps) {
return (
<Calendar
mode="single"
defaultMonth={defaultMonth ?? selected ?? new Date()}
selected={selected}
onSelect={onSelect}
className={cn("rounded-lg shadow-sm", className)}
/>
);
}
function Calendar({
className,
classNames,
showOutsideDays = true,
captionLayout = "label",
formatters,
components,
...props
}: React.ComponentProps<typeof DayPicker>) {
const defaultClassNames = getDefaultClassNames();
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn("bg-text text-desktop-primary p-3 [--cell-size:--spacing(10)]", className)}
captionLayout={captionLayout}
formatters={{
formatMonthDropdown: (date) => date.toLocaleString("default", { month: "short" }),
...formatters,
}}
classNames={{
root: cn("w-fit", defaultClassNames.root),
months: cn("flex gap-4 flex-col md:flex-row relative", defaultClassNames.months),
month: cn("flex flex-col w-full gap-3", defaultClassNames.month),
nav: cn("flex items-center w-full absolute top-0 inset-x-0 justify-between", defaultClassNames.nav),
button_previous: cn(
"size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
defaultClassNames.button_previous
),
button_next: cn(
"size-(--cell-size) aria-disabled:opacity-50 select-none",
defaultClassNames.button_next
),
month_caption: cn(
"flex items-center justify-center h-(--cell-size) w-full px-(--cell-size)",
defaultClassNames.month_caption
),
dropdowns: cn(
"w-full flex items-center text-sm font-medium justify-center h-(--cell-size) gap-1.5",
defaultClassNames.dropdowns
),
dropdown_root: cn(
"relative has-focus:border-ring border border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] rounded-md",
defaultClassNames.dropdown_root
),
dropdown: cn("absolute inset-0 opacity-0", defaultClassNames.dropdown),
caption_label: cn(
"select-none font-medium",
captionLayout === "label"
? "text-sm"
: "rounded-md pl-2 pr-1 flex items-center gap-1 text-sm h-8 [&>svg]:text-text-desktop-primary [&>svg]:size-3.5",
defaultClassNames.caption_label
),
table: "w-full border-collapse",
weekdays: cn("flex items-center justify-center", defaultClassNames.weekdays),
weekday: cn(
"text-neo-aqua rounded-md flex-1 font-normal text-sm select-none",
defaultClassNames.weekday
),
week: cn("flex w-full mt-2", defaultClassNames.week),
week_number_header: cn("select-none w-(--cell-size)", defaultClassNames.week_number_header),
week_number: cn("text-sm select-none text-desktop-primary", defaultClassNames.week_number),
day: cn(
"relative w-full h-full text-center [&:first-child[data-selected=true]_button]:rounded-l-md [&:last-child[data-selected=true]_button]:rounded-r-md group/day aspect-square select-none",
defaultClassNames.day
),
range_start: cn("rounded-l-md bg-accent", defaultClassNames.range_start),
range_middle: cn("rounded-none", defaultClassNames.range_middle),
range_end: cn("rounded-r-md bg-accent", defaultClassNames.range_end),
today: cn(
"bg-neo-aqua/10 text-accent-foreground rounded-md data-[selected=true]:rounded-none",
defaultClassNames.today
),
outside: cn(
"text-text-desktop-primary aria-selected:text-text-desktop-primary",
defaultClassNames.outside
),
disabled: cn("text-text-desktop-primary opacity-50", defaultClassNames.disabled),
hidden: cn("invisible", defaultClassNames.hidden),
...classNames,
}}
components={{
Root: ({ className, rootRef, ...props }) => (
<div data-slot="calendar" ref={rootRef} className={cn(className)} {...props} />
),
Chevron: ({ className, orientation, ...props }) => {
if (orientation === "left") {
return <Chevron className={cn("size-4 rotate-180", className)} {...props} />;
}
if (orientation === "right") {
return <Chevron className={cn("size-4 justify-self-end", className)} {...props} />;
}
return <Chevron className={cn("size-4 rotate-90", className)} {...props} />;
},
DayButton: CalendarDayButton,
WeekNumber: ({ children, ...props }) => (
<td {...props}>
<div className="flex size-(--cell-size) items-center justify-center text-center">
{children}
</div>
</td>
),
...components,
}}
{...props}
/>
);
}
function CalendarDayButton({ className, day, modifiers, ...props }: React.ComponentProps<typeof DayButton>) {
const defaultClassNames = getDefaultClassNames();
const ref = React.useRef<HTMLButtonElement>(null);
React.useEffect(() => {
if (modifiers.focused) ref.current?.focus();
}, [modifiers.focused]);
return (
<button
ref={ref}
data-day={day.date.toLocaleDateString()}
data-selected-single={
modifiers.selected && !modifiers.range_start && !modifiers.range_end && !modifiers.range_middle
}
data-range-start={modifiers.range_start}
data-range-end={modifiers.range_end}
data-range-middle={modifiers.range_middle}
className={cn(
"data-[selected-single=true]:bg-neo-aqua data-[selected-single=true]:text-text data-[range-middle=true]:bg-neo-aqua/20 data-[range-middle=true]:text-text data-[range-start=true]:bg-neo-aqua/20 data-[range-start=true]:text-text data-[range-end=true]:bg-neo-aqua/20 data-[range-end=true]:text-text flex aspect-square size-auto w-full min-w-(--cell-size) flex-col items-center justify-center gap-1 rounded leading-none group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:scale-90 data-[range-end=true]:rounded-md data-[range-end=true]:rounded-r-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md data-[range-start=true]:rounded-l-md [&>span]:text-xs [&>span]:opacity-70",
defaultClassNames.day,
className
)}
{...props}
/>
);
}

View File

@@ -0,0 +1,66 @@
"use client";
import { useModalStore } from "@/stores/useModalStore";
import { AnimatePresence, motion } from "framer-motion";
import { useEffect, useRef } from "react";
import { createPortal } from "react-dom";
const Modal = () => {
const isOpen = useModalStore((s) => s.isOpen);
const content = useModalStore((s) => s.content);
const closeModal = useModalStore((s) => s.closeModal);
const modalRef = useRef<HTMLDivElement>(null);
// Handle escape key to close
useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === "Escape") closeModal();
};
if (isOpen) {
document.addEventListener("keydown", handleEscape);
document.body.style.overflow = "hidden"; // Prevent scrolling
}
return () => {
document.removeEventListener("keydown", handleEscape);
document.body.style.overflow = "unset";
};
}, [isOpen, closeModal]);
const handleClickOutside = (e: React.MouseEvent) => {
if (modalRef.current && !modalRef.current.contains(e.target as Node)) {
closeModal();
}
};
if (!isOpen) return null;
return createPortal(
<AnimatePresence>
<motion.div
role="dialog"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.3 }}
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
onClick={handleClickOutside}
>
<motion.div
ref={modalRef}
initial={{ scale: 0.95, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.95, opacity: 0 }}
transition={{ duration: 0.3 }}
className="relative rounded-lg shadow-lg"
onClick={(e) => e.stopPropagation()}
>
{content}
</motion.div>
</motion.div>
</AnimatePresence>,
document.body
);
};
export default Modal;

View File

@@ -0,0 +1,42 @@
"use client";
import * as React from "react";
import * as PopoverPrimitive from "@radix-ui/react-popover";
import { cn } from "@/lib/utils";
function Popover({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Root>) {
return <PopoverPrimitive.Root data-slot="popover" {...props} />;
}
function PopoverTrigger({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />;
}
function PopoverContent({
className,
align = "center",
sideOffset = 4,
...props
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
data-slot="popover-content"
align={align}
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
);
}
function PopoverAnchor({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />;
}
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor };

View File

@@ -0,0 +1,147 @@
"use client";
import * as React from "react";
import * as SelectPrimitive from "@radix-ui/react-select";
import { cn } from "@/lib/utils";
import { CheckIcon, ChevronIcon } from "@/assets";
function Select({ ...props }: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />;
}
function SelectGroup({ ...props }: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />;
}
function SelectValue({ ...props }: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />;
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default";
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronIcon className="text-text size-4 rotate-270" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
);
}
function SelectContent({
className,
children,
position = "popper",
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
);
}
function SelectLabel({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
{...props}
/>
);
}
function SelectItem({ className, children, ...props }: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}
>
<span className="absolute left-2 flex size-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<CheckIcon size={4} />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
);
}
function SelectSeparator({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
{...props}
/>
);
}
function SelectScrollUpButton({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn("flex cursor-default items-center justify-center py-1", className)}
{...props}
>
<ChevronIcon className="text-desktop-primary size-4 rotate-90" />
</SelectPrimitive.ScrollUpButton>
);
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn("flex cursor-default items-center justify-center py-1", className)}
{...props}
>
<ChevronIcon className="text-desktop-primary size-4 rotate-270" />
</SelectPrimitive.ScrollDownButton>
);
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
};

View File

@@ -0,0 +1,14 @@
import { cn } from "@/lib/utils";
import { HTMLAttributes } from "react";
type SeparatorProps = {
className?: HTMLAttributes<HTMLDivElement> | string | undefined;
vertical?: boolean;
};
export default function Separator({ className, vertical }: SeparatorProps) {
if (vertical) {
return <div className={cn("border-text/15 h-full border-r", className)} />;
}
return <div className={cn("border-text/15 w-full border-b", className)} />;
}

View File

@@ -0,0 +1,492 @@
"use client";
import {
type Announcements,
closestCenter,
closestCorners,
DndContext,
type DndContextProps,
type DragEndEvent,
type DraggableAttributes,
type DraggableSyntheticListeners,
DragOverlay,
type DragStartEvent,
type DropAnimation,
defaultDropAnimationSideEffects,
KeyboardSensor,
MouseSensor,
type ScreenReaderInstructions,
TouchSensor,
type UniqueIdentifier,
useSensor,
useSensors,
} from "@dnd-kit/core";
import { restrictToHorizontalAxis, restrictToParentElement, restrictToVerticalAxis } from "@dnd-kit/modifiers";
import {
arrayMove,
horizontalListSortingStrategy,
SortableContext,
type SortableContextProps,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { Slot } from "@radix-ui/react-slot";
import * as React from "react";
import * as ReactDOM from "react-dom";
import { useComposedRefs } from "@/lib/compose-refs";
import { cn } from "@/lib/utils";
const orientationConfig = {
vertical: {
modifiers: [restrictToVerticalAxis, restrictToParentElement],
strategy: verticalListSortingStrategy,
collisionDetection: closestCenter,
},
horizontal: {
modifiers: [restrictToHorizontalAxis, restrictToParentElement],
strategy: horizontalListSortingStrategy,
collisionDetection: closestCenter,
},
mixed: {
modifiers: [restrictToParentElement],
strategy: undefined,
collisionDetection: closestCorners,
},
};
const ROOT_NAME = "Sortable";
const CONTENT_NAME = "SortableContent";
const ITEM_NAME = "SortableItem";
const ITEM_HANDLE_NAME = "SortableItemHandle";
const OVERLAY_NAME = "SortableOverlay";
interface SortableRootContextValue<T> {
id: string;
items: UniqueIdentifier[];
modifiers: DndContextProps["modifiers"];
strategy: SortableContextProps["strategy"];
activeId: UniqueIdentifier | null;
setActiveId: (id: UniqueIdentifier | null) => void;
getItemValue: (item: T) => UniqueIdentifier;
flatCursor: boolean;
}
const SortableRootContext = React.createContext<SortableRootContextValue<unknown> | null>(null);
function useSortableContext(consumerName: string) {
const context = React.useContext(SortableRootContext);
if (!context) {
throw new Error(`\`${consumerName}\` must be used within \`${ROOT_NAME}\``);
}
return context;
}
interface GetItemValue<T> {
/**
* Callback that returns a unique identifier for each sortable item. Required for array of objects.
* @example getItemValue={(item) => item.id}
*/
getItemValue: (item: T) => UniqueIdentifier;
}
type SortableRootProps<T> = DndContextProps &
(T extends object ? GetItemValue<T> : Partial<GetItemValue<T>>) & {
value: T[];
onValueChange?: (items: T[]) => void;
onMove?: (event: DragEndEvent & { activeIndex: number; overIndex: number }) => void;
strategy?: SortableContextProps["strategy"];
orientation?: "vertical" | "horizontal" | "mixed";
flatCursor?: boolean;
};
function SortableRoot<T>(props: SortableRootProps<T>) {
const {
value,
onValueChange,
collisionDetection,
modifiers,
strategy,
onMove,
orientation = "vertical",
flatCursor = false,
getItemValue: getItemValueProp,
accessibility,
...sortableProps
} = props;
const id = React.useId();
const [activeId, setActiveId] = React.useState<UniqueIdentifier | null>(null);
const sensors = useSensors(
useSensor(MouseSensor),
useSensor(TouchSensor),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
);
const config = React.useMemo(() => orientationConfig[orientation], [orientation]);
const getItemValue = React.useCallback(
(item: T): UniqueIdentifier => {
if (typeof item === "object" && !getItemValueProp) {
throw new Error("getItemValue is required when using array of objects");
}
return getItemValueProp ? getItemValueProp(item) : (item as UniqueIdentifier);
},
[getItemValueProp]
);
const items = React.useMemo(() => {
return value.map((item) => getItemValue(item));
}, [value, getItemValue]);
const onDragStart = (event: DragStartEvent) => {
sortableProps.onDragStart?.(event);
if (event.activatorEvent.defaultPrevented) return;
setActiveId(event.active.id);
};
const onDragEnd = (event: DragEndEvent) => {
sortableProps.onDragEnd?.(event);
if (event.activatorEvent.defaultPrevented) return;
const { active, over } = event;
if (over && active.id !== over?.id) {
const activeIndex = value.findIndex((item) => getItemValue(item) === active.id);
const overIndex = value.findIndex((item) => getItemValue(item) === over.id);
if (onMove) {
onMove({ ...event, activeIndex, overIndex });
} else {
onValueChange?.(arrayMove(value, activeIndex, overIndex));
}
}
setActiveId(null);
};
const onDragCancel = (event: DragEndEvent) => {
sortableProps.onDragCancel?.(event);
if (event.activatorEvent.defaultPrevented) return;
setActiveId(null);
};
const announcements: Announcements = React.useMemo(
() => ({
onDragStart({ active }) {
const activeValue = active.id.toString();
return `Grabbed sortable item "${activeValue}". Current position is ${active.data.current?.sortable.index + 1} of ${value.length}. Use arrow keys to move, space to drop.`;
},
onDragOver({ active, over }) {
if (over) {
const overIndex = over.data.current?.sortable.index ?? 0;
const activeIndex = active.data.current?.sortable.index ?? 0;
const moveDirection = overIndex > activeIndex ? "down" : "up";
const activeValue = active.id.toString();
return `Sortable item "${activeValue}" moved ${moveDirection} to position ${overIndex + 1} of ${value.length}.`;
}
return "Sortable item is no longer over a droppable area. Press escape to cancel.";
},
onDragEnd({ active, over }) {
const activeValue = active.id.toString();
if (over) {
const overIndex = over.data.current?.sortable.index ?? 0;
return `Sortable item "${activeValue}" dropped at position ${overIndex + 1} of ${value.length}.`;
}
return `Sortable item "${activeValue}" dropped. No changes were made.`;
},
onDragCancel({ active }) {
const activeIndex = active.data.current?.sortable.index ?? 0;
const activeValue = active.id.toString();
return `Sorting cancelled. Sortable item "${activeValue}" returned to position ${activeIndex + 1} of ${value.length}.`;
},
onDragMove({ active, over }) {
if (over) {
const overIndex = over.data.current?.sortable.index ?? 0;
const activeIndex = active.data.current?.sortable.index ?? 0;
const moveDirection = overIndex > activeIndex ? "down" : "up";
const activeValue = active.id.toString();
return `Sortable item "${activeValue}" is moving ${moveDirection} to position ${overIndex + 1} of ${value.length}.`;
}
return "Sortable item is no longer over a droppable area. Press escape to cancel.";
},
}),
[value]
);
const screenReaderInstructions: ScreenReaderInstructions = React.useMemo(
() => ({
draggable: `
To pick up a sortable item, press space or enter.
While dragging, use the ${orientation === "vertical" ? "up and down" : orientation === "horizontal" ? "left and right" : "arrow"} keys to move the item.
Press space or enter again to drop the item in its new position, or press escape to cancel.
`,
}),
[orientation]
);
const contextValue = React.useMemo(
() => ({
id,
items,
modifiers: modifiers ?? config.modifiers,
strategy: strategy ?? config.strategy,
activeId,
setActiveId,
getItemValue,
flatCursor,
}),
[id, items, modifiers, strategy, config.modifiers, config.strategy, activeId, getItemValue, flatCursor]
);
return (
<SortableRootContext.Provider value={contextValue as SortableRootContextValue<unknown>}>
<DndContext
collisionDetection={collisionDetection ?? config.collisionDetection}
modifiers={modifiers ?? config.modifiers}
sensors={sensors}
{...sortableProps}
id={id}
onDragStart={onDragStart}
onDragEnd={onDragEnd}
onDragCancel={onDragCancel}
accessibility={{
announcements,
screenReaderInstructions,
...accessibility,
}}
/>
</SortableRootContext.Provider>
);
}
const SortableContentContext = React.createContext<boolean>(false);
interface SortableContentProps extends React.ComponentProps<"div"> {
strategy?: SortableContextProps["strategy"];
children: React.ReactNode;
asChild?: boolean;
withoutSlot?: boolean;
}
function SortableContent(props: SortableContentProps) {
const { strategy: strategyProp, asChild, withoutSlot, children, ref, ...contentProps } = props;
const context = useSortableContext(CONTENT_NAME);
const ContentPrimitive = asChild ? Slot : "div";
return (
<SortableContentContext.Provider value={true}>
<SortableContext items={context.items} strategy={strategyProp ?? context.strategy}>
{withoutSlot ? (
children
) : (
<ContentPrimitive data-slot="sortable-content" {...contentProps} ref={ref}>
{children}
</ContentPrimitive>
)}
</SortableContext>
</SortableContentContext.Provider>
);
}
interface SortableItemContextValue {
id: string;
attributes: DraggableAttributes;
listeners: DraggableSyntheticListeners | undefined;
setActivatorNodeRef: (node: HTMLElement | null) => void;
isDragging?: boolean;
disabled?: boolean;
}
const SortableItemContext = React.createContext<SortableItemContextValue | null>(null);
function useSortableItemContext(consumerName: string) {
const context = React.useContext(SortableItemContext);
if (!context) {
throw new Error(`\`${consumerName}\` must be used within \`${ITEM_NAME}\``);
}
return context;
}
interface SortableItemProps extends React.ComponentProps<"div"> {
value: UniqueIdentifier;
asHandle?: boolean;
asChild?: boolean;
disabled?: boolean;
}
function SortableItem(props: SortableItemProps) {
const { value, style, asHandle, asChild, disabled, className, ref, ...itemProps } = props;
const inSortableContent = React.useContext(SortableContentContext);
const inSortableOverlay = React.useContext(SortableOverlayContext);
if (!inSortableContent && !inSortableOverlay) {
throw new Error(`\`${ITEM_NAME}\` must be used within \`${CONTENT_NAME}\` or \`${OVERLAY_NAME}\``);
}
if (value === "") {
throw new Error(`\`${ITEM_NAME}\` value cannot be an empty string`);
}
const context = useSortableContext(ITEM_NAME);
const id = React.useId();
const { attributes, listeners, setNodeRef, setActivatorNodeRef, transform, transition, isDragging } = useSortable({
id: value,
disabled,
});
const composedRef = useComposedRefs(ref, (node) => {
if (disabled) return;
setNodeRef(node);
if (asHandle) setActivatorNodeRef(node);
});
const composedStyle = React.useMemo<React.CSSProperties>(() => {
return {
transform: CSS.Translate.toString(transform),
transition,
...style,
};
}, [transform, transition, style]);
const itemContext = React.useMemo<SortableItemContextValue>(
() => ({
id,
attributes,
listeners,
setActivatorNodeRef,
isDragging,
disabled,
}),
[id, attributes, listeners, setActivatorNodeRef, isDragging, disabled]
);
const ItemPrimitive = asChild ? Slot : "div";
return (
<SortableItemContext.Provider value={itemContext}>
<ItemPrimitive
id={id}
data-disabled={disabled}
data-dragging={isDragging ? "" : undefined}
data-slot="sortable-item"
{...itemProps}
{...(asHandle && !disabled ? attributes : {})}
{...(asHandle && !disabled ? listeners : {})}
ref={composedRef}
style={composedStyle}
className={cn(
"focus-visible:ring-ring focus-visible:ring-1 focus-visible:ring-offset-1 focus-visible:outline-hidden",
{
"touch-none select-none": asHandle,
"cursor-default": context.flatCursor,
"data-dragging:cursor-grabbing": !context.flatCursor,
"cursor-grab": !isDragging && asHandle && !context.flatCursor,
"opacity-50": isDragging,
"pointer-events-none opacity-50": disabled,
},
className
)}
/>
</SortableItemContext.Provider>
);
}
interface SortableItemHandleProps extends React.ComponentProps<"button"> {
asChild?: boolean;
}
function SortableItemHandle(props: SortableItemHandleProps) {
const { asChild, disabled, className, ref, ...itemHandleProps } = props;
const context = useSortableContext(ITEM_HANDLE_NAME);
const itemContext = useSortableItemContext(ITEM_HANDLE_NAME);
const isDisabled = disabled ?? itemContext.disabled;
const composedRef = useComposedRefs(ref, (node) => {
if (!isDisabled) return;
itemContext.setActivatorNodeRef(node);
});
const HandlePrimitive = asChild ? Slot : "button";
return (
<HandlePrimitive
type="button"
aria-controls={itemContext.id}
data-disabled={isDisabled}
data-dragging={itemContext.isDragging ? "" : undefined}
data-slot="sortable-item-handle"
{...itemHandleProps}
{...(isDisabled ? {} : itemContext.attributes)}
{...(isDisabled ? {} : itemContext.listeners)}
ref={composedRef}
className={cn(
"select-none disabled:pointer-events-none disabled:opacity-50",
context.flatCursor ? "cursor-default" : "cursor-grab data-dragging:cursor-grabbing",
className
)}
disabled={isDisabled}
/>
);
}
const SortableOverlayContext = React.createContext(false);
const dropAnimation: DropAnimation = {
sideEffects: defaultDropAnimationSideEffects({
styles: {
active: {
opacity: "0.4",
},
},
}),
};
interface SortableOverlayProps extends Omit<React.ComponentProps<typeof DragOverlay>, "children"> {
container?: Element | DocumentFragment | null;
children?: ((params: { value: UniqueIdentifier }) => React.ReactNode) | React.ReactNode;
}
function SortableOverlay(props: SortableOverlayProps) {
const { container: containerProp, children, ...overlayProps } = props;
const context = useSortableContext(OVERLAY_NAME);
const [mounted, setMounted] = React.useState(false);
React.useLayoutEffect(() => setMounted(true), []);
const container = containerProp ?? (mounted ? globalThis.document?.body : null);
if (!container) return null;
return ReactDOM.createPortal(
<DragOverlay
dropAnimation={dropAnimation}
modifiers={context.modifiers}
className={cn(!context.flatCursor && "cursor-grabbing")}
{...overlayProps}
>
<SortableOverlayContext.Provider value={true}>
{context.activeId
? typeof children === "function"
? children({ value: context.activeId })
: children
: null}
</SortableOverlayContext.Provider>
</DragOverlay>,
container
);
}
export { SortableRoot as Sortable, SortableContent, SortableItem, SortableItemHandle, SortableOverlay };

View File

@@ -0,0 +1,48 @@
"use client";
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import * as React from "react";
import { cn } from "@/lib/utils";
function TooltipProvider({ delayDuration = 0, ...props }: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return <TooltipPrimitive.Provider data-slot="tooltip-provider" delayDuration={delayDuration} {...props} />;
}
function Tooltip({ ...props }: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return (
<TooltipProvider>
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
</TooltipProvider>
);
}
function TooltipTrigger({ ...props }: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />;
}
function TooltipContent({
className,
sideOffset = 0,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
"animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 bg-text text-desktop-primary z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
className
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="bg-text fill-text z-50 size-2.5 translate-y-[calc(-50%-2px)] rotate-45 rounded-xs" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
);
}
export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger };

View File

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

46
src/data/sidebarMenu.ts Normal file
View File

@@ -0,0 +1,46 @@
export const menuItems: Menu[] = [
{ title: "تصاویر دوربین ها", href: "/road-cameras" },
{ title: "شکایات", href: "/complaints" },
{
title: "اخبار",
children: [
{ title: "آخرین وضعیت راه های کشور", href: "/news/latest-roads-state" },
{ title: "کارگاه های جاده ای", href: "/news/road-works" },
{ title: "انسدادها", href: "/news/obstruction-list" },
{ title: "محدودیت تردد", href: "/news/limitations" },
{ title: "محدودیت اعلامی پلیس", href: "/news/police-traffic-restrictions" },
{ title: "اطلاعیه های هواشناسی", href: "/news/road-weather" },
],
},
// {
// title: "راه و ترافیک",
// children: [
// { title: "طبقه بندی راه های کشور", href: "#" },
// { title: "داده های تردد شمار", href: "#" },
// ],
// },
];
export const mobileMenuItems: Menu[] = [
{ title: "نقشه", href: "/" },
{ title: "تصاویر دوربین ها", href: "/road-cameras" },
{ title: "شکایات", href: "/complaints" },
{
title: "اخبار",
children: [
{ title: "آخرین وضعیت راه های کشور", href: "/news/latest-roads-state" },
{ title: "کارگاه های جاده ای", href: "/news/road-works" },
{ title: "انسدادها", href: "/news/obstruction-list" },
{ title: "محدودیت تردد", href: "/news/limitations" },
{ title: "محدودیت اعلامی پلیس", href: "/news/police-traffic-restrictions" },
{ title: "اطلاعیه های هواشناسی", href: "/news/road-weather" },
],
},
// {
// title: "راه و ترافیک",
// children: [
// { title: "طبقه بندی راه های کشور", href: "#" },
// { title: "داده های تردد شمار", href: "#" },
// ],
// },
];

104
src/hooks/useAuth.ts Normal file
View File

@@ -0,0 +1,104 @@
import { useAuthStore } from "@/stores/useAuthStore";
import useUserStore from "@/stores/userStore";
import { POST_LOGOUT, POST_OTP, POST_USER_PASS_LOGIN, POST_VERIFY_OTP_LOGIN_AND_SIGNUP } from "@/utils/apiRoutes";
import { useRouter, useSearchParams } from "next/navigation";
import useRequest from "./useRequest";
export function useAuth() {
const logoutClient = useUserStore((s) => s.logout);
const setIsAuthenticating = useAuthStore((s) => s.setIsAuthenticating);
const setTempPhoneNumber = useAuthStore((s) => s.setTempPhoneNumber);
const setAuthPanelState = useAuthStore((s) => s.setAuthPanelState);
const tempPhoneNumber = useAuthStore((s) => s.tempPhoneNumber);
const setToken = useUserStore((s) => s.setToken);
const setUser = useUserStore((s) => s.changeUser);
const router = useRouter();
const searchParams = useSearchParams();
const requestServer = useRequest({ auth: true, notification: true });
async function requestOtp(phone_number: string, aythType: "login" | "signup") {
setIsAuthenticating(true);
try {
await requestServer(POST_OTP, "post", {
data: { phone_number },
});
setTempPhoneNumber(phone_number);
setAuthPanelState(aythType === "login" ? "loginOtp" : "signUpOtp");
} catch (error) {
console.error("OTP request failed:", error);
} finally {
setIsAuthenticating(false);
}
}
async function VerifyOtpLoginAndSignup(otp: string) {
if (!tempPhoneNumber) return;
setIsAuthenticating(true);
try {
const response = await requestServer(POST_VERIFY_OTP_LOGIN_AND_SIGNUP, "post", {
data: {
phone_number: tempPhoneNumber,
otp,
},
});
const { token, user } = response.data;
setToken(token);
setUser(user);
const backUrl = searchParams.get("back_url");
if (backUrl) {
router.replace(backUrl);
} else {
router.replace("/");
}
} catch (error) {
console.error("OTP verification failed:", error);
} finally {
setIsAuthenticating(false);
}
}
async function loginWithUserName(userName: string, password: string) {
setIsAuthenticating(true);
try {
const response = await requestServer(POST_USER_PASS_LOGIN, "post", {
data: {
username: userName,
password,
},
});
const { token, user } = response.data;
setToken(token);
setUser(user);
} catch (error) {
console.error("Login failed:", error);
} finally {
setIsAuthenticating(false);
}
}
async function logout() {
setIsAuthenticating(true);
try {
await requestServer(POST_LOGOUT, "post");
logoutClient();
window.location.reload();
} catch (error) {
console.error("Logout failed:", error);
} finally {
setIsAuthenticating(false);
}
}
return {
logout,
requestOtp,
signIn: VerifyOtpLoginAndSignup,
loginWithUserName,
};
}

View File

@@ -0,0 +1,22 @@
import { useCallback, useEffect, useRef } from "react";
export function useDebouncedCallback<T extends (...args: never[]) => void>(callback: T, delay: number) {
const timer = useRef<NodeJS.Timeout | null>(null);
const debounced = useCallback(
(...args: Parameters<T>) => {
if (timer.current) clearTimeout(timer.current);
timer.current = setTimeout(() => callback(...args), delay);
},
[callback, delay]
);
const cancel = useCallback(() => {
if (timer.current) clearTimeout(timer.current);
timer.current = null;
}, []);
useEffect(() => cancel, [cancel]);
return { debounced, cancel };
}

17
src/hooks/useDevice.ts Normal file
View File

@@ -0,0 +1,17 @@
import { useEffect, useState } from "react";
export default function useDevice() {
const [isMobile, setIsMobile] = useState(false);
useEffect(() => {
const checkIsMobile = () => {
setIsMobile(window.innerWidth < 768);
};
checkIsMobile();
window.addEventListener("resize", checkIsMobile);
return () => window.removeEventListener("resize", checkIsMobile);
}, []);
return isMobile;
}

92
src/hooks/useRequest.ts Normal file
View File

@@ -0,0 +1,92 @@
"use client";
import axios from "axios";
import { useTranslations } from "next-intl";
import useUserStore from "@/stores/userStore";
import ToastStore from "@/stores/useToastStore";
import { Notifications } from "@/notifications";
export interface RequestOptions {
auth?: boolean;
data?: Record<string, string> | FormData;
requestOptions?: {
headers?: Record<string, string>;
signal?: AbortSignal;
[key: string]: any;
};
notification?: boolean;
pending?: boolean;
success?: {
notification: {
show: boolean;
};
};
failed?: {
notification: {
show: boolean;
};
};
}
const defaultOptions: RequestOptions = {
auth: false,
data: {},
requestOptions: {
headers: {},
},
notification: true,
pending: true,
success: {
notification: {
show: true,
},
},
failed: {
notification: {
show: true,
},
},
};
const useRequest = (initOptions: RequestOptions) => {
const t = useTranslations();
const { token, clearToken } = useUserStore();
const { pushToastList, dismissToastList } = ToastStore();
let _options = { ...defaultOptions, ...initOptions };
function requestServer(url: string, method: string = "get", options?: RequestOptions) {
_options = { ..._options, ...options };
if (_options.auth) {
const isFormData = _options.data instanceof FormData;
_options = {
..._options,
requestOptions: {
...(_options.requestOptions || {}),
headers: {
...(_options.requestOptions?.headers || {}),
...(isFormData ? {} : { "Content-Type": "application/json" }),
authorization: `Bearer ${token}`,
},
},
};
}
return Notifications(
axios({
url,
method,
data: _options.data,
..._options.requestOptions,
}),
{
t,
handlers: { pushToastList, dismissToastList, clearToken },
options: _options,
}
);
}
return requestServer;
};
export default useRequest;

12
src/i18n/request.ts Normal file
View File

@@ -0,0 +1,12 @@
import { cookies } from "next/headers";
import { getRequestConfig } from "next-intl/server";
export default getRequestConfig(async () => {
const cookieStore = await cookies();
const locale = cookieStore.get("language")?.value || "fa";
return {
locale,
messages: (await import(`../../messages/${locale}.json`)).default,
};
});

66
src/lib/compose-refs.ts Normal file
View File

@@ -0,0 +1,66 @@
/**
* @see https://github.com/radix-ui/primitives/blob/main/packages/react/compose-refs/src/compose-refs.tsx
*/
import * as React from "react";
type PossibleRef<T> = React.Ref<T> | undefined;
/**
* Set a given ref to a given value
* This utility takes care of different types of refs: callback refs and RefObject(s)
*/
function setRef<T>(ref: PossibleRef<T>, value: T) {
if (typeof ref === "function") {
return ref(value);
}
if (ref !== null && ref !== undefined) {
ref.current = value;
}
}
/**
* A utility to compose multiple refs together
* Accepts callback refs and RefObject(s)
*/
function composeRefs<T>(...refs: PossibleRef<T>[]): React.RefCallback<T> {
return (node) => {
let hasCleanup = false;
const cleanups = refs.map((ref) => {
const cleanup = setRef(ref, node);
if (!hasCleanup && typeof cleanup === "function") {
hasCleanup = true;
}
return cleanup;
});
// React <19 will log an error to the console if a callback ref returns a
// value. We don't use ref cleanups internally so this will only happen if a
// user's ref callback returns a value, which we only expect if they are
// using the cleanup functionality added in React 19.
if (hasCleanup) {
return () => {
for (let i = 0; i < cleanups.length; i++) {
const cleanup = cleanups[i];
if (typeof cleanup === "function") {
cleanup();
} else {
setRef(refs[i], null);
}
}
};
}
};
}
/**
* A custom hook that composes multiple refs
* Accepts callback refs and RefObject(s)
*/
function useComposedRefs<T>(...refs: PossibleRef<T>[]): React.RefCallback<T> {
// biome-ignore lint/correctness/useExhaustiveDependencies: we want to memoize by all values
return React.useCallback(composeRefs(...refs), refs);
}
export { composeRefs, useComposedRefs };

6
src/lib/utils.ts Normal file
View File

@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

133
src/notifications/index.tsx Normal file
View File

@@ -0,0 +1,133 @@
import toast from "react-hot-toast";
import { AxiosResponse } from "axios";
type ToastType = "pending" | "error" | "warning" | "success";
type TranslationFunction = (key: string) => string;
interface ToastHandlers {
pushToastList: (type: ToastType, id: string) => void;
dismissToastList: (types: ToastType[]) => void;
clearToken: () => void;
}
interface RequestOptions {
auth?: boolean;
data?: Record<string, string> | FormData;
requestOptions?: {
headers?: Record<string, string>;
signal?: AbortSignal;
[key: string]: any;
};
notification?: boolean;
pending?: boolean;
success?: {
notification: {
show: boolean;
};
};
failed?: {
notification: {
show: boolean;
};
};
}
interface ToastPromiseOptions {
t: TranslationFunction;
handlers: ToastHandlers;
options: RequestOptions;
}
export const Notifications = (promise: Promise<AxiosResponse>, { t, handlers, options }: ToastPromiseOptions) => {
const { pushToastList, dismissToastList, clearToken } = handlers;
const showNotification = options.notification ?? true;
const showPending = options.pending ?? true;
const showSuccess = options.success?.notification?.show ?? true;
const showFailed = options.failed?.notification?.show ?? true;
if (!showNotification) return promise;
return toast.promise(
promise,
{
loading: showPending ? t("Notifications.pending") : "",
success: (response: AxiosResponse) => {
if (showSuccess) {
dismissToastList(["pending", "warning", "error", "success"]);
pushToastList("success", String(response.status));
}
return t("Notifications.success");
},
error: (error: any) => {
if (showFailed) {
dismissToastList(["pending", "warning", "error", "success"]);
}
if (!showFailed) return t("Notifications.error");
let message = t("Notifications.error");
if (error.response) {
const status = error.response.status;
const responseData = error.response.data;
// build default message with status code
message = `${status} - ${t("Notifications.error")}`;
if (status >= 500 && status <= 599) {
pushToastList("warning", String(status));
} else if (status >= 400 && status <= 499) {
switch (status) {
case 401:
clearToken();
pushToastList("error", String(status));
break;
case 422:
// append validation messages
if (responseData?.message) {
if (Array.isArray(responseData.message)) {
message = responseData.message.join(", ");
} else {
message = responseData.message;
}
}
if (responseData?.errors) {
const errs: string[] = [];
Object.keys(responseData.errors).forEach((key) => {
const msg = responseData.errors[key]?.[0];
if (msg) errs.push(msg);
});
if (errs.length) {
message = errs.join(", ");
}
}
break;
case 429:
message = `${status} - Too many requests`;
pushToastList("error", String(status));
break;
default:
pushToastList("error", String(status));
break;
}
} else {
pushToastList("error", t("Notifications.error"));
}
} else if (error.request) {
message = t("Notifications.error") + " (No response)";
pushToastList("error", message);
} else {
message = t("Notifications.error") + " (Request failed)";
pushToastList("error", message);
}
return message;
},
},
{
position: "top-center",
duration: 4000,
}
);
};

View File

@@ -0,0 +1,40 @@
"use client";
import { AnimatePresence, motion } from "framer-motion";
import { ThemeProvider } from "next-themes";
import { useEffect, useState } from "react";
export function ClientAppProvider({ children }: { children: React.ReactNode }) {
const [mounted, setMounted] = useState(false);
// wait for hydration
useEffect(() => {
setMounted(true);
}, []);
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>
</ThemeProvider>
);
}

View File

@@ -0,0 +1,25 @@
"use client";
import React, { useEffect, useState, useEffectEvent } from "react";
import { useDeviceStore } from "@/stores/useDeviceStore";
import useDevice from "@/hooks/useDevice";
export default function DeviceProvider({ children }: { children: React.ReactNode }) {
const isMobileDevice = useDevice();
const setIsMobile = useDeviceStore((state) => state.setIsMobile);
const [ready, setReady] = useState(false);
const syncDevice = useEffectEvent((isMobile: boolean) => {
setIsMobile(isMobile);
setReady(true);
});
useEffect(() => {
syncDevice(isMobileDevice);
}, [isMobileDevice]);
if (!ready) return null;
return <>{children}</>;
}

View File

@@ -0,0 +1,6 @@
"use client";
import { MapProvider } from "@vis.gl/react-maplibre";
export default function MapProviderComponent({ children }: { children: React.ReactNode }) {
return <MapProvider>{children}</MapProvider>;
}

26
src/proxy.ts Normal file
View File

@@ -0,0 +1,26 @@
import { NextRequest, NextResponse } from "next/server";
import { protectedRoutes } from "./data/protectedRoutes";
export function proxy(req: NextRequest) {
const token = req.cookies.get("_token")?.value;
const { pathname } = req.nextUrl;
const needsAuth = protectedRoutes.some((route) => pathname.startsWith(route));
if (pathname.startsWith("/auth") && token) {
return NextResponse.redirect(new URL("/", req.url));
}
if (needsAuth && !token) {
const loginUrl = new URL("/auth", req.url);
loginUrl.searchParams.set("back_url", pathname);
return NextResponse.redirect(loginUrl);
}
return NextResponse.next();
}
export const config = {
matcher: ["/auth", "/app-complaints", "/panel/:path*", "/complaints/complaints-panel"],
};

100
src/stores/LocationStore.ts Normal file
View File

@@ -0,0 +1,100 @@
import { create } from "zustand";
export type LatLng = { lat: number; lng: number };
export type Mode = "origin" | "destination" | "routing";
export type LocationPoint = {
id: string;
locationType: "origin" | "destination";
coordinate: LatLng | null;
uniqueId: string;
};
type LocationState = {
userLocation: LatLng | null;
origin: LatLng | null;
destination: LatLng | null;
locationPoints: LocationPoint[];
currentStep: Mode;
setUserLocation: (val: LatLng) => void;
setLocation: (mode: Mode, val: LatLng | null) => void;
setLocationPoints: (val: LocationPoint[]) => void;
setStep: (step: Mode) => void;
reset: () => void;
};
const buildLocationPoints = (origin: LatLng | null, destination: LatLng | null): LocationPoint[] => {
const points: LocationPoint[] = [];
points.push({
id: "origin",
locationType: "origin",
coordinate: origin,
uniqueId: "0",
});
points.push({
id: "destination",
locationType: "destination",
coordinate: destination,
uniqueId: "1",
});
return points;
};
export const useLocationStore = create<LocationState>((set, get) => ({
userLocation: null,
origin: null,
destination: null,
locationPoints: [
{
id: "origin",
locationType: "origin",
coordinate: null,
uniqueId: "0",
},
{
id: "destination",
locationType: "destination",
coordinate: null,
uniqueId: "1",
},
],
currentStep: "destination",
setUserLocation: (val) => set({ userLocation: val }),
setLocation: (mode, val) =>
set((state) => {
const updated = { ...state, [mode]: val };
return {
...updated,
locationPoints: buildLocationPoints(updated.origin, updated.destination),
};
}),
setLocationPoints: (val) => {
// Create new points in reordered order, but reassign types/ids based on positions
const newPoints: LocationPoint[] = val.map((point, index, arr) => ({
uniqueId: point.uniqueId, // Preserve persistent ID
id: index === 0 ? "origin" : "destination",
locationType: index === 0 ? "origin" : "destination",
coordinate: point.coordinate,
}));
const origin = newPoints[0]?.coordinate ?? null;
const destination = newPoints.at(-1)?.coordinate ?? null;
set({
locationPoints: newPoints,
origin,
destination,
});
},
setStep: (step) => set({ currentStep: step }),
reset: () =>
set({
origin: null,
destination: null,
currentStep: "destination",
}),
}));

46
src/stores/cityStore.ts Normal file
View File

@@ -0,0 +1,46 @@
import { create } from "zustand";
import { GET_CITIES } from "@/utils/apiRoutes";
interface City {
id: number;
name: string;
}
interface CityStore {
city: string;
selectedProvinceID: string | null;
cities: City[];
setCity: (value: string) => void;
setCities: (data: City[]) => void;
setSelectedProvinceID: (value: string) => void;
fetchCities: (provinceId: string) => Promise<void>;
loadCities: boolean;
errorCities: null | string;
}
const useCityStore = create<CityStore>((set, get) => ({
city: "",
selectedProvinceID: null,
cities: [],
loadCities: false,
errorCities: null,
setCity: (city) => set({ city }),
setSelectedProvinceID: (selectedProvinceID) => set({ selectedProvinceID }),
setCities: (cities) => set({ cities }),
fetchCities: async (provinceId: string) => {
if (!provinceId) return;
set({ loadCities: true, errorCities: null });
try {
const res = await fetch(`${GET_CITIES}/${provinceId}`);
const data = await res.json();
set({ cities: data.data.cities });
} catch (e) {
console.error("خطا در دریافت شهرها", e);
set({ errorCities: "خطا در دریافت شهرها" });
} finally {
set({ loadCities: false });
}
},
}));
export default useCityStore;

View File

@@ -0,0 +1,42 @@
import { create } from "zustand";
import { GET_PROVINCES } from "@/utils/apiRoutes";
interface Province {
id: number;
name_fa: string;
}
interface ProvinceStore {
province: string;
provinces: Province[];
setProvince: (value: string) => void;
setProvinces: (data: Province[]) => void;
fetchProvinces: () => Promise<void>;
loadProvinces: boolean;
errorProvinces: null | string;
}
const useProvinceStore = create<ProvinceStore>((set, get) => ({
province: "",
provinces: [],
loadProvinces: false,
errorProvinces: null,
setProvince: (province) => set({ province }),
setProvinces: (provinces) => set({ provinces }),
fetchProvinces: async () => {
if (get().provinces.length) return;
set({ loadProvinces: true });
try {
const res = await fetch(GET_PROVINCES);
const data = await res.json();
set({ provinces: data.data.provinces });
} catch (e) {
console.error("خطا در دریافت استان‌ها", e);
set({ errorProvinces: "خطا در دریافت استان ها" });
} finally {
set({ loadProvinces: false });
}
},
}));
export default useProvinceStore;

View File

@@ -0,0 +1,30 @@
import { create } from "zustand";
export type AuthPanelStateType =
| "loginWithPhoneNumber"
| "loginWithUserName"
| "signUp"
| "signUpOtp"
| "loginOtp"
| "startAuth";
type AuthStore = {
authPanelState: AuthPanelStateType;
setAuthPanelState: (state: AuthPanelStateType) => void;
tempPhoneNumber: string | null;
setTempPhoneNumber: (phone: string | null) => void;
isAuthenticating: boolean;
setIsAuthenticating: (isAuthenticating: boolean) => void;
};
export const useAuthStore = create<AuthStore>((set) => ({
authPanelState: "loginWithPhoneNumber", // Default
tempPhoneNumber: null,
isAuthenticating: false,
setAuthPanelState: (state) => set({ authPanelState: state }),
setTempPhoneNumber: (phone) => set({ tempPhoneNumber: phone }),
setIsAuthenticating: (isAuthenticating) => set({ isAuthenticating }),
}));

View File

@@ -0,0 +1,22 @@
import { ReactNode } from "react";
import { create } from "zustand";
interface BottomSheetOptions {
interactiveBackground?: boolean;
}
interface BottomSheetState {
isOpen: boolean;
content: ReactNode | null;
options: BottomSheetOptions;
openBottomSheet: (content: ReactNode, options?: BottomSheetOptions) => void;
closeBottomSheet: () => void;
}
export const useBottomSheetStore = create<BottomSheetState>((set) => ({
isOpen: false,
content: null,
options: {},
openBottomSheet: (content, options) => set({ isOpen: true, content, options }),
closeBottomSheet: () => set({ isOpen: false, content: null, options: {} }),
}));

View File

@@ -0,0 +1,11 @@
import { create } from "zustand";
interface DeviceState {
isMobile: boolean;
setIsMobile: (isMobile: boolean) => void;
}
export const useDeviceStore = create<DeviceState>((set) => ({
isMobile: false,
setIsMobile: (isMobile) => set({ isMobile }),
}));

View File

@@ -0,0 +1,37 @@
import { create } from "zustand";
import Cookies from "js-cookie";
import { Language } from "@/types/language.types";
interface LanguageState {
localization: Language;
language_lists: Language[];
changeLanguage: (languageKey: string) => void;
}
export const useLanguageStore = create<LanguageState>((set, get) => ({
language_lists: [
{
id: 0,
key: "fa",
language: "فارسی",
direction: "rtl",
},
],
localization: {
id: 0,
key: "fa",
language: "فارسی",
direction: "rtl",
},
changeLanguage: (languageKey) => {
const currentState = get();
const selectedLanguage = currentState.language_lists.find((language) => language.key === languageKey);
if (selectedLanguage) {
Cookies.set("language", languageKey, { expires: 365, secure: true });
set({ localization: selectedLanguage });
}
},
}));

View File

@@ -0,0 +1,16 @@
import { create } from "zustand";
import { ReactNode } from "react";
interface ModalState {
isOpen: boolean;
content: ReactNode | null;
openModal: (content: ReactNode) => void;
closeModal: () => void;
}
export const useModalStore = create<ModalState>((set) => ({
isOpen: false,
content: null,
openModal: (content) => set({ isOpen: true, content }),
closeModal: () => set({ isOpen: false, content: null }),
}));

View File

@@ -0,0 +1,51 @@
"use client";
import { create } from "zustand";
import { toast } from "react-hot-toast";
export type ToastType = "pending" | "error" | "warning" | "success";
interface ToastStoreState {
toastLists: {
pending: string[];
error: string[];
warning: string[];
success: string[];
};
pushToastList: (toast_type: ToastType, toast_id: string) => void;
dismissToastList: (toast_types: ToastType[]) => void;
}
const ToastStore = create<ToastStoreState>((set) => ({
toastLists: {
pending: [],
error: [],
warning: [],
success: [],
},
pushToastList: (toast_type, toast_id) =>
set((state) => ({
toastLists: {
...state.toastLists,
[toast_type]: [...state.toastLists[toast_type], toast_id],
},
})),
dismissToastList: (toast_types) =>
set((state) => {
toast_types.forEach((type) => {
state.toastLists[type].forEach((id) => {
toast.dismiss(id);
});
});
const newToastLists = { ...state.toastLists };
toast_types.forEach((type) => {
newToastLists[type] = [];
});
return { toastLists: newToastLists };
}),
}));
export default ToastStore;

157
src/stores/userStore.ts Normal file
View File

@@ -0,0 +1,157 @@
"use client";
import { GET_USER_ROUTE } from "@/utils/apiRoutes";
import axios from "axios";
import Cookies from "js-cookie";
import { create } from "zustand";
import { persist } from "zustand/middleware";
interface User {
id: number;
first_name: string | null;
last_name: string | null;
phone_number: string;
email: string | null;
gender: "male" | "female" | null;
job_field: string | null;
province_id: number | null;
province_fa: string | null;
city_id: number | null;
city_fa: string | null;
national_code: string | null;
vehicle_type: string | null;
is_valid: boolean;
}
interface UserStoreState {
isAuth: boolean;
errorState: boolean;
userChangedLanguage: boolean;
initAuthState: boolean;
token: string | null;
user: User;
clearUser: () => void;
changeUser: (user: User) => void;
changeUserLanguage: (language: string) => void;
changeAuthState: (isAuth: boolean) => void;
changeInitAuth: (initAuthState: boolean) => void;
changeLanguageState: (userChangedLanguage: boolean) => void;
clearToken: () => void;
logout: () => Promise<void>;
setToken: (token: string) => void;
getUser: () => Promise<void>;
initialize: () => Promise<void>;
}
const defaultUser: User = {
id: 0,
first_name: null,
last_name: null,
phone_number: "",
email: "",
gender: null,
job_field: null,
province_id: null,
province_fa: null,
city_id: null,
city_fa: null,
national_code: null,
vehicle_type: null,
is_valid: false,
};
const useUserStore = create<UserStoreState>()(
persist(
(set, get) => ({
isAuth: false,
userChangedLanguage: false,
initAuthState: false,
errorState: false,
token: null,
user: defaultUser,
clearUser: () => set({ user: defaultUser }),
changeUser: (user: User) => set({ user, isAuth: true }),
changeUserLanguage: (language: string) =>
set((state) => ({
user: { ...state.user, user_language: language },
})),
changeAuthState: (isAuth: boolean) => set({ isAuth }),
changeInitAuth: (initAuthState: boolean) => set({ initAuthState }),
changeLanguageState: (userChangedLanguage: boolean) => set({ userChangedLanguage }),
clearToken: () => {
set({ token: null, isAuth: false });
set({ initAuthState: true });
Cookies.remove("_token");
},
setToken: (token: string) => {
set({ token, initAuthState: true });
Cookies.set("_token", token, { sameSite: "strict" });
},
logout: async () => {
get().clearUser();
get().changeAuthState(false);
get().changeInitAuth(true);
Cookies.remove("_token");
set({ isAuth: false });
},
getUser: async () => {
const token = get().token;
if (!token) return;
try {
const { data } = await axios.get(GET_USER_ROUTE, {
headers: { authorization: `Bearer ${token}` },
});
set({
user: data,
isAuth: true,
initAuthState: true,
errorState: false,
});
Cookies.set("_token", token, { sameSite: "strict" });
} catch (error: unknown) {
if (axios.isAxiosError(error) && error.response?.status === 401) {
get().clearToken();
}
set({
isAuth: false,
initAuthState: true,
errorState: true,
});
}
},
initialize: async () => {
const token = get().token;
if (!token) {
get().clearUser();
get().changeAuthState(false);
get().changeInitAuth(true);
get().changeLanguageState(false);
get().clearToken();
return;
}
await get().getUser();
},
}),
{
name: "user-storage",
partialize: (state) => ({
token: state.token,
user: state.user,
isAuth: state.isAuth,
}),
}
)
);
export default useUserStore;

116
src/styles/globals.css Normal file
View File

@@ -0,0 +1,116 @@
@import "tailwindcss";
*,
*::before,
*::after,
*::-webkit-scrollbar {
box-sizing: border-box;
direction: rtl;
}
* {
-ms-overflow-style: none;
scrollbar-width: none;
}
*::-webkit-scrollbar {
display: none;
}
@layer utilities {
.bg-gradient-center-to-left {
background: linear-gradient(to left, rgba(0, 0, 0, 0.3) 50%, rgba(0, 0, 0, 0.5) 100%);
}
}
/* disable pull to refresh */
html,
body {
overscroll-behavior: none;
}
body {
margin: 0;
font-family: "Ravi", sans-serif;
-moz-font-feature-settings: "ss02";
-webkit-font-feature-settings: "ss02";
font-feature-settings: "ss02";
background-color: #fff;
color: #333;
}
* {
font-family: "Ravi", sans-serif;
-moz-font-feature-settings: "ss02";
-webkit-font-feature-settings: "ss02";
font-feature-settings: "ss02";
}
body,
main,
div,
section,
header,
footer,
button,
input {
transition:
background-color 0.3s ease,
color 0.3s ease,
border-color 0.3s ease;
}
button {
cursor: pointer;
}
.container {
width: 100%;
@media (width >= 40rem /* 640px */) {
max-width: 95%;
}
@media (width >= 48rem /* 768px */) {
max-width: 95%;
}
@media (width >= 64rem /* 1024px */) {
max-width: 95%;
}
@media (width >= 80rem /* 1280px */) {
max-width: 95%;
}
@media (width >= 96rem /* 1536px */) {
max-width: 90%;
}
}
:root {
--background: #ffffff;
--foreground: #171717;
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
}
body {
background: var(--background);
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
}
.maplibregl-popup-content {
width: 0px !important;
height: 0px !important;
background-color: transparent !important;
position: relative !important;
}
.maplibregl-popup-tip {
display: none !important;
}

View File

@@ -0,0 +1,6 @@
export interface Language {
id: number;
key: string;
language: string;
direction: "rtl" | "ltr";
}

View File

@@ -0,0 +1,5 @@
type Menu = {
title: string;
href?: string;
children?: Menu[];
};

4
src/types/types.ts Normal file
View File

@@ -0,0 +1,4 @@
export interface LatLng {
lat: number;
lng: number;
}

View File

@@ -0,0 +1,16 @@
"use client";
import { useEffect } from "react";
import toast, { Toaster, useToasterStore } from "react-hot-toast";
export default function ToastProvider() {
const { toasts } = useToasterStore();
useEffect(() => {
toasts
.filter((t) => t.visible)
.filter((_, i) => i >= 3)
.forEach((t) => toast.dismiss(t.id));
}, [toasts]);
return <Toaster />;
}

View File

@@ -0,0 +1,15 @@
"use client";
import { useEffect } from "react";
import useUserStore from "@/stores/userStore";
const UserInitializer = () => {
const initialize = useUserStore((state) => state.initialize);
useEffect(() => {
initialize();
}, [initialize]);
return null;
};
export default UserInitializer;

29
src/utils/WithTooltip.tsx Normal file
View File

@@ -0,0 +1,29 @@
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/UI/Tooltip";
import React from "react";
type WithTooltipProps = {
children: React.ReactNode;
delayDuration?: number;
tooltipContent: string;
side: "top" | "right" | "bottom" | "left";
sideOffset?: number;
};
export default function WithTooltip({
children,
delayDuration = 400,
tooltipContent,
side,
sideOffset,
}: WithTooltipProps) {
return (
<Tooltip delayDuration={delayDuration}>
<TooltipTrigger asChild>
<div>{children}</div>
</TooltipTrigger>
<TooltipContent side={side} sideOffset={sideOffset}>
<p>{tooltipContent}</p>
</TooltipContent>
</Tooltip>
);
}

15
src/utils/apiRoutes.ts Normal file
View File

@@ -0,0 +1,15 @@
export const api = process.env.NEXT_PUBLIC_API_URL;
// auth
export const POST_OTP = api + "/api/sms/otp";
export const POST_VERIFY_OTP_LOGIN_AND_SIGNUP = api + "/api/login/otp";
export const POST_USER_PASS_LOGIN = api + "/api/login/user-pass";
export const POST_LOGOUT = api + "/api/logout";
// auth
// user panel
export const GET_USER_ROUTE = api + "/api/client";
// user panel
export const GET_PROVINCES = api + "/provinces";
export const GET_CITIES = api + "/api/cities";

63
src/utils/locateUser.ts Normal file
View File

@@ -0,0 +1,63 @@
import { useLocationStore } from "@/stores/LocationStore";
import { LatLng } from "@/types/types";
import toast from "react-hot-toast";
export function getUserLocation(): Promise<LatLng> {
return new Promise((resolve, reject) => {
if (!navigator.geolocation) {
toast.error("مرورگر شما از موقعیت‌یابی پشتیبانی نمی‌کند");
reject(new Error("Geolocation is not supported by your browser."));
return;
}
let didRespond = false;
const timeout = setTimeout(() => {
if (!didRespond) {
didRespond = true;
toast.error("دریافت موقعیت با مشکل مواجه شد");
reject(new Error("The request to get your location timed out."));
return;
}
}, 500); // TODO: refactor to handle more gracefully
navigator.geolocation.getCurrentPosition(
(position) => {
if (didRespond) return;
didRespond = true;
clearTimeout(timeout);
const { latitude, longitude } = position.coords;
useLocationStore.setState({ userLocation: { lat: latitude, lng: longitude } });
resolve({ lat: latitude, lng: longitude });
},
(error) => {
if (didRespond) return;
didRespond = true;
clearTimeout(timeout);
switch (error.code) {
case error.PERMISSION_DENIED:
reject(new Error("Location access is denied. Please enable it in your browser settings."));
toast.error("اجازه دسترسی به موقعیت رد شده است");
break;
case error.POSITION_UNAVAILABLE:
reject(new Error("Location information is unavailable."));
toast.error("موقعیت در دسترس نیست");
break;
case error.TIMEOUT:
reject(new Error("The request to get your location timed out."));
toast.error("مدت زمان برای دریافت موقعیت منقضی شد");
break;
default:
toast.error("خطای ناشناخته");
reject(new Error("An unknown error occurred while getting location."));
break;
}
},
{
enableHighAccuracy: true,
maximumAge: 0,
}
);
});
}

58
src/utils/useMapMove.ts Normal file
View File

@@ -0,0 +1,58 @@
import { LatLng } from "@/types/types";
import { MapRef, useMap } from "@vis.gl/react-maplibre";
import maplibregl, { LngLatLike } from "maplibre-gl";
export function useMapMove() {
const { homePageMap: map } = useMap();
function mapMove(evt: any) {
const view = evt.target.getCenter();
const zoom = evt.target.getZoom();
const viewState = {
longitude: view.lng,
latitude: view.lat,
zoom,
};
localStorage.setItem("mapview", JSON.stringify(viewState));
}
function flyToBounds(coordinates: LngLatLike[], mapRef: MapRef | undefined = map, duration = 1000) {
if (!mapRef) return;
const bounds = new maplibregl.LngLatBounds();
coordinates.forEach((c) => bounds.extend(c));
const vw = window.innerWidth;
const vh = window.innerHeight;
mapRef.fitBounds(bounds, {
padding: {
top: vh / 5,
right: vw / 3,
bottom: vh / 3,
left: vw / 5,
},
duration,
});
}
const flyToLocation = (location: LatLng, zoom: number = 15, mapRef: MapRef | undefined = map) => {
if (mapRef) {
mapRef.getMap().flyTo({
center: [location.lng, location.lat],
zoom: zoom,
speed: 1.2,
curve: 1.42,
essential: true,
});
}
};
return {
mapMove,
flyToBounds,
flyToLocation,
};
}