install packages
This commit is contained in:
Binary file not shown.
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 2.3 KiB |
@@ -1,26 +0,0 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
:root {
|
||||
--background: #ffffff;
|
||||
--foreground: #171717;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-geist-sans);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--background: #0a0a0a;
|
||||
--foreground: #ededed;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
}
|
||||
@@ -1,34 +1,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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
2
src/assets/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default as CheckIcon } from "&/icons/check-icon.svg";
|
||||
export { default as ChevronIcon } from "&/icons/chevron-icon.svg";
|
||||
26
src/assets/translations/filepond-fa.json
Normal file
26
src/assets/translations/filepond-fa.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"labelInvalidField": "این فیلد شامل فایلهای نامعتبر است",
|
||||
"labelFileWaitingForSize": "در انتظار بررسی سایز فایل",
|
||||
"labelFileSizeNotAvailable": "اندازه فایل {fileName} موجود نیست",
|
||||
"labelFileLoading": "در حال بارگذاری",
|
||||
"labelFileLoadError": "خطا هنگام بارگذاری",
|
||||
"labelFileProcessing": "در حال آپلود",
|
||||
"labelFileProcessingComplete": "آپلود کامل شد",
|
||||
"labelFileProcessingAborted": "آپلود لغو شد",
|
||||
"labelFileProcessingError": "خطا هنگام آپلود",
|
||||
"labelTapToCancel": "برای لغو کلیک کنید",
|
||||
"labelTapToRetry": "برای تلاش دوباره کلیک کنید",
|
||||
"labelTapToUndo": "برای بازگشت کلیک کنید",
|
||||
"labelButtonRemoveItem": "حذف",
|
||||
"labelButtonAbortItemLoad": "لغو",
|
||||
"labelButtonRetryItemLoad": "تلاش دوباره",
|
||||
"labelButtonAbortItemProcessing": "لغو",
|
||||
"labelButtonRetryItemProcessing": "تلاش دوباره",
|
||||
"labelButtonProcessItem": "آپلود",
|
||||
"labelMaxFileSizeExceeded": "حجم فایل بیش از حد مجاز است",
|
||||
"labelMaxFileSize": "حداکثر حجم مجاز: {filesize}",
|
||||
"labelFileTypeNotAllowed": "نوع فایل {fileName} مجاز نیست",
|
||||
"labelFileSizeMegabytes": "مگابایت",
|
||||
"labelFileSizeKilobytes": "کیلوبایت",
|
||||
"labelFileSizeGigabytes": "گیگابایت"
|
||||
}
|
||||
82
src/components/UI/BottomSheet.tsx
Normal file
82
src/components/UI/BottomSheet.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
"use client";
|
||||
|
||||
import { useBottomSheetStore } from "@/stores/useBottomSheetStore";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { useEffect } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
const BottomSheet = () => {
|
||||
const { isOpen, content, options, closeBottomSheet } = useBottomSheetStore();
|
||||
|
||||
const interactive = options?.interactiveBackground ?? false;
|
||||
|
||||
const viewportHeight = typeof window !== "undefined" ? window.innerHeight : 0;
|
||||
|
||||
// Escape key (only active for modal mode)
|
||||
useEffect(() => {
|
||||
if (!isOpen || interactive) return;
|
||||
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") closeBottomSheet();
|
||||
};
|
||||
|
||||
document.addEventListener("keydown", handleEscape);
|
||||
document.body.style.overflow = "hidden";
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("keydown", handleEscape);
|
||||
document.body.style.overflow = "unset";
|
||||
};
|
||||
}, [isOpen, interactive, closeBottomSheet]);
|
||||
|
||||
return createPortal(
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<>
|
||||
{/* Non-interactive background mode (modal) */}
|
||||
{!interactive && (
|
||||
<motion.div
|
||||
key="modal-overlay"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.25 }}
|
||||
className="fixed inset-0 z-50 bg-black/50"
|
||||
onClick={closeBottomSheet}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* The sheet (works in both modes) */}
|
||||
<motion.div
|
||||
key="sheet"
|
||||
initial={{ y: viewportHeight }}
|
||||
animate={{ y: 0 }}
|
||||
exit={{ y: viewportHeight }}
|
||||
transition={{ type: "spring", damping: 30, stiffness: 300 }}
|
||||
drag="y"
|
||||
dragConstraints={{ top: 0, bottom: 0 }}
|
||||
dragElastic={{ top: 0, bottom: 0.25 }}
|
||||
onDragEnd={(e, info) => {
|
||||
if (info.offset.y > 100 || info.velocity.y > 500) {
|
||||
closeBottomSheet();
|
||||
}
|
||||
}}
|
||||
className="bg-pwa-primary fixed inset-x-0 bottom-0 z-[60] mx-auto w-full max-w-md rounded-t-xl shadow-lg"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex justify-center py-2">
|
||||
<div className="h-1 w-12 rounded-full bg-gray-400" />
|
||||
</div>
|
||||
|
||||
<div className="overflow-y-auto p-4" style={{ maxHeight: "90vh" }} dir="rtl">
|
||||
{content}
|
||||
</div>
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>,
|
||||
document.body
|
||||
);
|
||||
};
|
||||
|
||||
export default BottomSheet;
|
||||
161
src/components/UI/Calendar.tsx
Normal file
161
src/components/UI/Calendar.tsx
Normal file
@@ -0,0 +1,161 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import * as React from "react";
|
||||
import { Chevron, DayButton, getDefaultClassNames } from "react-day-picker";
|
||||
import { DayPicker } from "react-day-picker/persian";
|
||||
|
||||
interface CalendarHijriProps {
|
||||
selected?: Date;
|
||||
onSelect?: (date: Date | undefined) => void;
|
||||
defaultMonth?: Date;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function CalendarHijri({ selected, onSelect, defaultMonth, className }: CalendarHijriProps) {
|
||||
return (
|
||||
<Calendar
|
||||
mode="single"
|
||||
defaultMonth={defaultMonth ?? selected ?? new Date()}
|
||||
selected={selected}
|
||||
onSelect={onSelect}
|
||||
className={cn("rounded-lg shadow-sm", className)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function Calendar({
|
||||
className,
|
||||
classNames,
|
||||
showOutsideDays = true,
|
||||
captionLayout = "label",
|
||||
formatters,
|
||||
components,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DayPicker>) {
|
||||
const defaultClassNames = getDefaultClassNames();
|
||||
|
||||
return (
|
||||
<DayPicker
|
||||
showOutsideDays={showOutsideDays}
|
||||
className={cn("bg-text text-desktop-primary p-3 [--cell-size:--spacing(10)]", className)}
|
||||
captionLayout={captionLayout}
|
||||
formatters={{
|
||||
formatMonthDropdown: (date) => date.toLocaleString("default", { month: "short" }),
|
||||
...formatters,
|
||||
}}
|
||||
classNames={{
|
||||
root: cn("w-fit", defaultClassNames.root),
|
||||
months: cn("flex gap-4 flex-col md:flex-row relative", defaultClassNames.months),
|
||||
month: cn("flex flex-col w-full gap-3", defaultClassNames.month),
|
||||
nav: cn("flex items-center w-full absolute top-0 inset-x-0 justify-between", defaultClassNames.nav),
|
||||
button_previous: cn(
|
||||
"size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
|
||||
defaultClassNames.button_previous
|
||||
),
|
||||
button_next: cn(
|
||||
"size-(--cell-size) aria-disabled:opacity-50 select-none",
|
||||
defaultClassNames.button_next
|
||||
),
|
||||
month_caption: cn(
|
||||
"flex items-center justify-center h-(--cell-size) w-full px-(--cell-size)",
|
||||
defaultClassNames.month_caption
|
||||
),
|
||||
dropdowns: cn(
|
||||
"w-full flex items-center text-sm font-medium justify-center h-(--cell-size) gap-1.5",
|
||||
defaultClassNames.dropdowns
|
||||
),
|
||||
dropdown_root: cn(
|
||||
"relative has-focus:border-ring border border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] rounded-md",
|
||||
defaultClassNames.dropdown_root
|
||||
),
|
||||
dropdown: cn("absolute inset-0 opacity-0", defaultClassNames.dropdown),
|
||||
caption_label: cn(
|
||||
"select-none font-medium",
|
||||
captionLayout === "label"
|
||||
? "text-sm"
|
||||
: "rounded-md pl-2 pr-1 flex items-center gap-1 text-sm h-8 [&>svg]:text-text-desktop-primary [&>svg]:size-3.5",
|
||||
defaultClassNames.caption_label
|
||||
),
|
||||
table: "w-full border-collapse",
|
||||
weekdays: cn("flex items-center justify-center", defaultClassNames.weekdays),
|
||||
weekday: cn(
|
||||
"text-neo-aqua rounded-md flex-1 font-normal text-sm select-none",
|
||||
defaultClassNames.weekday
|
||||
),
|
||||
week: cn("flex w-full mt-2", defaultClassNames.week),
|
||||
week_number_header: cn("select-none w-(--cell-size)", defaultClassNames.week_number_header),
|
||||
week_number: cn("text-sm select-none text-desktop-primary", defaultClassNames.week_number),
|
||||
day: cn(
|
||||
"relative w-full h-full text-center [&:first-child[data-selected=true]_button]:rounded-l-md [&:last-child[data-selected=true]_button]:rounded-r-md group/day aspect-square select-none",
|
||||
defaultClassNames.day
|
||||
),
|
||||
range_start: cn("rounded-l-md bg-accent", defaultClassNames.range_start),
|
||||
range_middle: cn("rounded-none", defaultClassNames.range_middle),
|
||||
range_end: cn("rounded-r-md bg-accent", defaultClassNames.range_end),
|
||||
today: cn(
|
||||
"bg-neo-aqua/10 text-accent-foreground rounded-md data-[selected=true]:rounded-none",
|
||||
defaultClassNames.today
|
||||
),
|
||||
outside: cn(
|
||||
"text-text-desktop-primary aria-selected:text-text-desktop-primary",
|
||||
defaultClassNames.outside
|
||||
),
|
||||
disabled: cn("text-text-desktop-primary opacity-50", defaultClassNames.disabled),
|
||||
hidden: cn("invisible", defaultClassNames.hidden),
|
||||
...classNames,
|
||||
}}
|
||||
components={{
|
||||
Root: ({ className, rootRef, ...props }) => (
|
||||
<div data-slot="calendar" ref={rootRef} className={cn(className)} {...props} />
|
||||
),
|
||||
Chevron: ({ className, orientation, ...props }) => {
|
||||
if (orientation === "left") {
|
||||
return <Chevron className={cn("size-4 rotate-180", className)} {...props} />;
|
||||
}
|
||||
if (orientation === "right") {
|
||||
return <Chevron className={cn("size-4 justify-self-end", className)} {...props} />;
|
||||
}
|
||||
return <Chevron className={cn("size-4 rotate-90", className)} {...props} />;
|
||||
},
|
||||
DayButton: CalendarDayButton,
|
||||
WeekNumber: ({ children, ...props }) => (
|
||||
<td {...props}>
|
||||
<div className="flex size-(--cell-size) items-center justify-center text-center">
|
||||
{children}
|
||||
</div>
|
||||
</td>
|
||||
),
|
||||
...components,
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CalendarDayButton({ className, day, modifiers, ...props }: React.ComponentProps<typeof DayButton>) {
|
||||
const defaultClassNames = getDefaultClassNames();
|
||||
const ref = React.useRef<HTMLButtonElement>(null);
|
||||
React.useEffect(() => {
|
||||
if (modifiers.focused) ref.current?.focus();
|
||||
}, [modifiers.focused]);
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
data-day={day.date.toLocaleDateString()}
|
||||
data-selected-single={
|
||||
modifiers.selected && !modifiers.range_start && !modifiers.range_end && !modifiers.range_middle
|
||||
}
|
||||
data-range-start={modifiers.range_start}
|
||||
data-range-end={modifiers.range_end}
|
||||
data-range-middle={modifiers.range_middle}
|
||||
className={cn(
|
||||
"data-[selected-single=true]:bg-neo-aqua data-[selected-single=true]:text-text data-[range-middle=true]:bg-neo-aqua/20 data-[range-middle=true]:text-text data-[range-start=true]:bg-neo-aqua/20 data-[range-start=true]:text-text data-[range-end=true]:bg-neo-aqua/20 data-[range-end=true]:text-text flex aspect-square size-auto w-full min-w-(--cell-size) flex-col items-center justify-center gap-1 rounded leading-none group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:scale-90 data-[range-end=true]:rounded-md data-[range-end=true]:rounded-r-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md data-[range-start=true]:rounded-l-md [&>span]:text-xs [&>span]:opacity-70",
|
||||
defaultClassNames.day,
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
66
src/components/UI/Modal.tsx
Normal file
66
src/components/UI/Modal.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
"use client";
|
||||
|
||||
import { useModalStore } from "@/stores/useModalStore";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
const Modal = () => {
|
||||
const isOpen = useModalStore((s) => s.isOpen);
|
||||
const content = useModalStore((s) => s.content);
|
||||
const closeModal = useModalStore((s) => s.closeModal);
|
||||
|
||||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Handle escape key to close
|
||||
useEffect(() => {
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") closeModal();
|
||||
};
|
||||
if (isOpen) {
|
||||
document.addEventListener("keydown", handleEscape);
|
||||
document.body.style.overflow = "hidden"; // Prevent scrolling
|
||||
}
|
||||
return () => {
|
||||
document.removeEventListener("keydown", handleEscape);
|
||||
document.body.style.overflow = "unset";
|
||||
};
|
||||
}, [isOpen, closeModal]);
|
||||
|
||||
const handleClickOutside = (e: React.MouseEvent) => {
|
||||
if (modalRef.current && !modalRef.current.contains(e.target as Node)) {
|
||||
closeModal();
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return createPortal(
|
||||
<AnimatePresence>
|
||||
<motion.div
|
||||
role="dialog"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
|
||||
onClick={handleClickOutside}
|
||||
>
|
||||
<motion.div
|
||||
ref={modalRef}
|
||||
initial={{ scale: 0.95, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
exit={{ scale: 0.95, opacity: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="relative rounded-lg shadow-lg"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{content}
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</AnimatePresence>,
|
||||
document.body
|
||||
);
|
||||
};
|
||||
|
||||
export default Modal;
|
||||
42
src/components/UI/Popover.tsx
Normal file
42
src/components/UI/Popover.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as PopoverPrimitive from "@radix-ui/react-popover";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Popover({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Root>) {
|
||||
return <PopoverPrimitive.Root data-slot="popover" {...props} />;
|
||||
}
|
||||
|
||||
function PopoverTrigger({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
|
||||
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />;
|
||||
}
|
||||
|
||||
function PopoverContent({
|
||||
className,
|
||||
align = "center",
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
|
||||
return (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Content
|
||||
data-slot="popover-content"
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
|
||||
function PopoverAnchor({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
|
||||
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />;
|
||||
}
|
||||
|
||||
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor };
|
||||
147
src/components/UI/Select.tsx
Normal file
147
src/components/UI/Select.tsx
Normal file
@@ -0,0 +1,147 @@
|
||||
"use client";
|
||||
import * as React from "react";
|
||||
import * as SelectPrimitive from "@radix-ui/react-select";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { CheckIcon, ChevronIcon } from "@/assets";
|
||||
function Select({ ...props }: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
||||
return <SelectPrimitive.Root data-slot="select" {...props} />;
|
||||
}
|
||||
function SelectGroup({ ...props }: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
||||
return <SelectPrimitive.Group data-slot="select-group" {...props} />;
|
||||
}
|
||||
function SelectValue({ ...props }: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
||||
return <SelectPrimitive.Value data-slot="select-value" {...props} />;
|
||||
}
|
||||
function SelectTrigger({
|
||||
className,
|
||||
size = "default",
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
|
||||
size?: "sm" | "default";
|
||||
}) {
|
||||
return (
|
||||
<SelectPrimitive.Trigger
|
||||
data-slot="select-trigger"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronIcon className="text-text size-4 rotate-270" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
);
|
||||
}
|
||||
function SelectContent({
|
||||
className,
|
||||
children,
|
||||
position = "popper",
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
|
||||
return (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
data-slot="select-content"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
function SelectLabel({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.Label>) {
|
||||
return (
|
||||
<SelectPrimitive.Label
|
||||
data-slot="select-label"
|
||||
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
function SelectItem({ className, children, ...props }: React.ComponentProps<typeof SelectPrimitive.Item>) {
|
||||
return (
|
||||
<SelectPrimitive.Item
|
||||
data-slot="select-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<CheckIcon size={4} />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
);
|
||||
}
|
||||
function SelectSeparator({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.Separator>) {
|
||||
return (
|
||||
<SelectPrimitive.Separator
|
||||
data-slot="select-separator"
|
||||
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
function SelectScrollUpButton({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
data-slot="select-scroll-up-button"
|
||||
className={cn("flex cursor-default items-center justify-center py-1", className)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronIcon className="text-desktop-primary size-4 rotate-90" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
);
|
||||
}
|
||||
function SelectScrollDownButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
data-slot="select-scroll-down-button"
|
||||
className={cn("flex cursor-default items-center justify-center py-1", className)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronIcon className="text-desktop-primary size-4 rotate-270" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
);
|
||||
}
|
||||
export {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectScrollDownButton,
|
||||
SelectScrollUpButton,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
};
|
||||
14
src/components/UI/Separator.tsx
Normal file
14
src/components/UI/Separator.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import { HTMLAttributes } from "react";
|
||||
|
||||
type SeparatorProps = {
|
||||
className?: HTMLAttributes<HTMLDivElement> | string | undefined;
|
||||
vertical?: boolean;
|
||||
};
|
||||
|
||||
export default function Separator({ className, vertical }: SeparatorProps) {
|
||||
if (vertical) {
|
||||
return <div className={cn("border-text/15 h-full border-r", className)} />;
|
||||
}
|
||||
return <div className={cn("border-text/15 w-full border-b", className)} />;
|
||||
}
|
||||
492
src/components/UI/Sortable.tsx
Normal file
492
src/components/UI/Sortable.tsx
Normal file
@@ -0,0 +1,492 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
type Announcements,
|
||||
closestCenter,
|
||||
closestCorners,
|
||||
DndContext,
|
||||
type DndContextProps,
|
||||
type DragEndEvent,
|
||||
type DraggableAttributes,
|
||||
type DraggableSyntheticListeners,
|
||||
DragOverlay,
|
||||
type DragStartEvent,
|
||||
type DropAnimation,
|
||||
defaultDropAnimationSideEffects,
|
||||
KeyboardSensor,
|
||||
MouseSensor,
|
||||
type ScreenReaderInstructions,
|
||||
TouchSensor,
|
||||
type UniqueIdentifier,
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from "@dnd-kit/core";
|
||||
import { restrictToHorizontalAxis, restrictToParentElement, restrictToVerticalAxis } from "@dnd-kit/modifiers";
|
||||
import {
|
||||
arrayMove,
|
||||
horizontalListSortingStrategy,
|
||||
SortableContext,
|
||||
type SortableContextProps,
|
||||
sortableKeyboardCoordinates,
|
||||
useSortable,
|
||||
verticalListSortingStrategy,
|
||||
} from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import * as React from "react";
|
||||
import * as ReactDOM from "react-dom";
|
||||
import { useComposedRefs } from "@/lib/compose-refs";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const orientationConfig = {
|
||||
vertical: {
|
||||
modifiers: [restrictToVerticalAxis, restrictToParentElement],
|
||||
strategy: verticalListSortingStrategy,
|
||||
collisionDetection: closestCenter,
|
||||
},
|
||||
horizontal: {
|
||||
modifiers: [restrictToHorizontalAxis, restrictToParentElement],
|
||||
strategy: horizontalListSortingStrategy,
|
||||
collisionDetection: closestCenter,
|
||||
},
|
||||
mixed: {
|
||||
modifiers: [restrictToParentElement],
|
||||
strategy: undefined,
|
||||
collisionDetection: closestCorners,
|
||||
},
|
||||
};
|
||||
|
||||
const ROOT_NAME = "Sortable";
|
||||
const CONTENT_NAME = "SortableContent";
|
||||
const ITEM_NAME = "SortableItem";
|
||||
const ITEM_HANDLE_NAME = "SortableItemHandle";
|
||||
const OVERLAY_NAME = "SortableOverlay";
|
||||
|
||||
interface SortableRootContextValue<T> {
|
||||
id: string;
|
||||
items: UniqueIdentifier[];
|
||||
modifiers: DndContextProps["modifiers"];
|
||||
strategy: SortableContextProps["strategy"];
|
||||
activeId: UniqueIdentifier | null;
|
||||
setActiveId: (id: UniqueIdentifier | null) => void;
|
||||
getItemValue: (item: T) => UniqueIdentifier;
|
||||
flatCursor: boolean;
|
||||
}
|
||||
|
||||
const SortableRootContext = React.createContext<SortableRootContextValue<unknown> | null>(null);
|
||||
|
||||
function useSortableContext(consumerName: string) {
|
||||
const context = React.useContext(SortableRootContext);
|
||||
if (!context) {
|
||||
throw new Error(`\`${consumerName}\` must be used within \`${ROOT_NAME}\``);
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
interface GetItemValue<T> {
|
||||
/**
|
||||
* Callback that returns a unique identifier for each sortable item. Required for array of objects.
|
||||
* @example getItemValue={(item) => item.id}
|
||||
*/
|
||||
getItemValue: (item: T) => UniqueIdentifier;
|
||||
}
|
||||
|
||||
type SortableRootProps<T> = DndContextProps &
|
||||
(T extends object ? GetItemValue<T> : Partial<GetItemValue<T>>) & {
|
||||
value: T[];
|
||||
onValueChange?: (items: T[]) => void;
|
||||
onMove?: (event: DragEndEvent & { activeIndex: number; overIndex: number }) => void;
|
||||
strategy?: SortableContextProps["strategy"];
|
||||
orientation?: "vertical" | "horizontal" | "mixed";
|
||||
flatCursor?: boolean;
|
||||
};
|
||||
|
||||
function SortableRoot<T>(props: SortableRootProps<T>) {
|
||||
const {
|
||||
value,
|
||||
onValueChange,
|
||||
collisionDetection,
|
||||
modifiers,
|
||||
strategy,
|
||||
onMove,
|
||||
orientation = "vertical",
|
||||
flatCursor = false,
|
||||
getItemValue: getItemValueProp,
|
||||
accessibility,
|
||||
...sortableProps
|
||||
} = props;
|
||||
|
||||
const id = React.useId();
|
||||
const [activeId, setActiveId] = React.useState<UniqueIdentifier | null>(null);
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(MouseSensor),
|
||||
useSensor(TouchSensor),
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates,
|
||||
})
|
||||
);
|
||||
const config = React.useMemo(() => orientationConfig[orientation], [orientation]);
|
||||
|
||||
const getItemValue = React.useCallback(
|
||||
(item: T): UniqueIdentifier => {
|
||||
if (typeof item === "object" && !getItemValueProp) {
|
||||
throw new Error("getItemValue is required when using array of objects");
|
||||
}
|
||||
return getItemValueProp ? getItemValueProp(item) : (item as UniqueIdentifier);
|
||||
},
|
||||
[getItemValueProp]
|
||||
);
|
||||
|
||||
const items = React.useMemo(() => {
|
||||
return value.map((item) => getItemValue(item));
|
||||
}, [value, getItemValue]);
|
||||
|
||||
const onDragStart = (event: DragStartEvent) => {
|
||||
sortableProps.onDragStart?.(event);
|
||||
|
||||
if (event.activatorEvent.defaultPrevented) return;
|
||||
|
||||
setActiveId(event.active.id);
|
||||
};
|
||||
|
||||
const onDragEnd = (event: DragEndEvent) => {
|
||||
sortableProps.onDragEnd?.(event);
|
||||
|
||||
if (event.activatorEvent.defaultPrevented) return;
|
||||
|
||||
const { active, over } = event;
|
||||
if (over && active.id !== over?.id) {
|
||||
const activeIndex = value.findIndex((item) => getItemValue(item) === active.id);
|
||||
const overIndex = value.findIndex((item) => getItemValue(item) === over.id);
|
||||
|
||||
if (onMove) {
|
||||
onMove({ ...event, activeIndex, overIndex });
|
||||
} else {
|
||||
onValueChange?.(arrayMove(value, activeIndex, overIndex));
|
||||
}
|
||||
}
|
||||
setActiveId(null);
|
||||
};
|
||||
|
||||
const onDragCancel = (event: DragEndEvent) => {
|
||||
sortableProps.onDragCancel?.(event);
|
||||
|
||||
if (event.activatorEvent.defaultPrevented) return;
|
||||
|
||||
setActiveId(null);
|
||||
};
|
||||
|
||||
const announcements: Announcements = React.useMemo(
|
||||
() => ({
|
||||
onDragStart({ active }) {
|
||||
const activeValue = active.id.toString();
|
||||
return `Grabbed sortable item "${activeValue}". Current position is ${active.data.current?.sortable.index + 1} of ${value.length}. Use arrow keys to move, space to drop.`;
|
||||
},
|
||||
onDragOver({ active, over }) {
|
||||
if (over) {
|
||||
const overIndex = over.data.current?.sortable.index ?? 0;
|
||||
const activeIndex = active.data.current?.sortable.index ?? 0;
|
||||
const moveDirection = overIndex > activeIndex ? "down" : "up";
|
||||
const activeValue = active.id.toString();
|
||||
return `Sortable item "${activeValue}" moved ${moveDirection} to position ${overIndex + 1} of ${value.length}.`;
|
||||
}
|
||||
return "Sortable item is no longer over a droppable area. Press escape to cancel.";
|
||||
},
|
||||
onDragEnd({ active, over }) {
|
||||
const activeValue = active.id.toString();
|
||||
if (over) {
|
||||
const overIndex = over.data.current?.sortable.index ?? 0;
|
||||
return `Sortable item "${activeValue}" dropped at position ${overIndex + 1} of ${value.length}.`;
|
||||
}
|
||||
return `Sortable item "${activeValue}" dropped. No changes were made.`;
|
||||
},
|
||||
onDragCancel({ active }) {
|
||||
const activeIndex = active.data.current?.sortable.index ?? 0;
|
||||
const activeValue = active.id.toString();
|
||||
return `Sorting cancelled. Sortable item "${activeValue}" returned to position ${activeIndex + 1} of ${value.length}.`;
|
||||
},
|
||||
onDragMove({ active, over }) {
|
||||
if (over) {
|
||||
const overIndex = over.data.current?.sortable.index ?? 0;
|
||||
const activeIndex = active.data.current?.sortable.index ?? 0;
|
||||
const moveDirection = overIndex > activeIndex ? "down" : "up";
|
||||
const activeValue = active.id.toString();
|
||||
return `Sortable item "${activeValue}" is moving ${moveDirection} to position ${overIndex + 1} of ${value.length}.`;
|
||||
}
|
||||
return "Sortable item is no longer over a droppable area. Press escape to cancel.";
|
||||
},
|
||||
}),
|
||||
[value]
|
||||
);
|
||||
|
||||
const screenReaderInstructions: ScreenReaderInstructions = React.useMemo(
|
||||
() => ({
|
||||
draggable: `
|
||||
To pick up a sortable item, press space or enter.
|
||||
While dragging, use the ${orientation === "vertical" ? "up and down" : orientation === "horizontal" ? "left and right" : "arrow"} keys to move the item.
|
||||
Press space or enter again to drop the item in its new position, or press escape to cancel.
|
||||
`,
|
||||
}),
|
||||
[orientation]
|
||||
);
|
||||
|
||||
const contextValue = React.useMemo(
|
||||
() => ({
|
||||
id,
|
||||
items,
|
||||
modifiers: modifiers ?? config.modifiers,
|
||||
strategy: strategy ?? config.strategy,
|
||||
activeId,
|
||||
setActiveId,
|
||||
getItemValue,
|
||||
flatCursor,
|
||||
}),
|
||||
[id, items, modifiers, strategy, config.modifiers, config.strategy, activeId, getItemValue, flatCursor]
|
||||
);
|
||||
|
||||
return (
|
||||
<SortableRootContext.Provider value={contextValue as SortableRootContextValue<unknown>}>
|
||||
<DndContext
|
||||
collisionDetection={collisionDetection ?? config.collisionDetection}
|
||||
modifiers={modifiers ?? config.modifiers}
|
||||
sensors={sensors}
|
||||
{...sortableProps}
|
||||
id={id}
|
||||
onDragStart={onDragStart}
|
||||
onDragEnd={onDragEnd}
|
||||
onDragCancel={onDragCancel}
|
||||
accessibility={{
|
||||
announcements,
|
||||
screenReaderInstructions,
|
||||
...accessibility,
|
||||
}}
|
||||
/>
|
||||
</SortableRootContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
const SortableContentContext = React.createContext<boolean>(false);
|
||||
|
||||
interface SortableContentProps extends React.ComponentProps<"div"> {
|
||||
strategy?: SortableContextProps["strategy"];
|
||||
children: React.ReactNode;
|
||||
asChild?: boolean;
|
||||
withoutSlot?: boolean;
|
||||
}
|
||||
|
||||
function SortableContent(props: SortableContentProps) {
|
||||
const { strategy: strategyProp, asChild, withoutSlot, children, ref, ...contentProps } = props;
|
||||
|
||||
const context = useSortableContext(CONTENT_NAME);
|
||||
|
||||
const ContentPrimitive = asChild ? Slot : "div";
|
||||
|
||||
return (
|
||||
<SortableContentContext.Provider value={true}>
|
||||
<SortableContext items={context.items} strategy={strategyProp ?? context.strategy}>
|
||||
{withoutSlot ? (
|
||||
children
|
||||
) : (
|
||||
<ContentPrimitive data-slot="sortable-content" {...contentProps} ref={ref}>
|
||||
{children}
|
||||
</ContentPrimitive>
|
||||
)}
|
||||
</SortableContext>
|
||||
</SortableContentContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
interface SortableItemContextValue {
|
||||
id: string;
|
||||
attributes: DraggableAttributes;
|
||||
listeners: DraggableSyntheticListeners | undefined;
|
||||
setActivatorNodeRef: (node: HTMLElement | null) => void;
|
||||
isDragging?: boolean;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const SortableItemContext = React.createContext<SortableItemContextValue | null>(null);
|
||||
|
||||
function useSortableItemContext(consumerName: string) {
|
||||
const context = React.useContext(SortableItemContext);
|
||||
if (!context) {
|
||||
throw new Error(`\`${consumerName}\` must be used within \`${ITEM_NAME}\``);
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
interface SortableItemProps extends React.ComponentProps<"div"> {
|
||||
value: UniqueIdentifier;
|
||||
asHandle?: boolean;
|
||||
asChild?: boolean;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
function SortableItem(props: SortableItemProps) {
|
||||
const { value, style, asHandle, asChild, disabled, className, ref, ...itemProps } = props;
|
||||
|
||||
const inSortableContent = React.useContext(SortableContentContext);
|
||||
const inSortableOverlay = React.useContext(SortableOverlayContext);
|
||||
|
||||
if (!inSortableContent && !inSortableOverlay) {
|
||||
throw new Error(`\`${ITEM_NAME}\` must be used within \`${CONTENT_NAME}\` or \`${OVERLAY_NAME}\``);
|
||||
}
|
||||
|
||||
if (value === "") {
|
||||
throw new Error(`\`${ITEM_NAME}\` value cannot be an empty string`);
|
||||
}
|
||||
|
||||
const context = useSortableContext(ITEM_NAME);
|
||||
const id = React.useId();
|
||||
const { attributes, listeners, setNodeRef, setActivatorNodeRef, transform, transition, isDragging } = useSortable({
|
||||
id: value,
|
||||
disabled,
|
||||
});
|
||||
|
||||
const composedRef = useComposedRefs(ref, (node) => {
|
||||
if (disabled) return;
|
||||
setNodeRef(node);
|
||||
if (asHandle) setActivatorNodeRef(node);
|
||||
});
|
||||
|
||||
const composedStyle = React.useMemo<React.CSSProperties>(() => {
|
||||
return {
|
||||
transform: CSS.Translate.toString(transform),
|
||||
transition,
|
||||
...style,
|
||||
};
|
||||
}, [transform, transition, style]);
|
||||
|
||||
const itemContext = React.useMemo<SortableItemContextValue>(
|
||||
() => ({
|
||||
id,
|
||||
attributes,
|
||||
listeners,
|
||||
setActivatorNodeRef,
|
||||
isDragging,
|
||||
disabled,
|
||||
}),
|
||||
[id, attributes, listeners, setActivatorNodeRef, isDragging, disabled]
|
||||
);
|
||||
|
||||
const ItemPrimitive = asChild ? Slot : "div";
|
||||
|
||||
return (
|
||||
<SortableItemContext.Provider value={itemContext}>
|
||||
<ItemPrimitive
|
||||
id={id}
|
||||
data-disabled={disabled}
|
||||
data-dragging={isDragging ? "" : undefined}
|
||||
data-slot="sortable-item"
|
||||
{...itemProps}
|
||||
{...(asHandle && !disabled ? attributes : {})}
|
||||
{...(asHandle && !disabled ? listeners : {})}
|
||||
ref={composedRef}
|
||||
style={composedStyle}
|
||||
className={cn(
|
||||
"focus-visible:ring-ring focus-visible:ring-1 focus-visible:ring-offset-1 focus-visible:outline-hidden",
|
||||
{
|
||||
"touch-none select-none": asHandle,
|
||||
"cursor-default": context.flatCursor,
|
||||
"data-dragging:cursor-grabbing": !context.flatCursor,
|
||||
"cursor-grab": !isDragging && asHandle && !context.flatCursor,
|
||||
"opacity-50": isDragging,
|
||||
"pointer-events-none opacity-50": disabled,
|
||||
},
|
||||
className
|
||||
)}
|
||||
/>
|
||||
</SortableItemContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
interface SortableItemHandleProps extends React.ComponentProps<"button"> {
|
||||
asChild?: boolean;
|
||||
}
|
||||
|
||||
function SortableItemHandle(props: SortableItemHandleProps) {
|
||||
const { asChild, disabled, className, ref, ...itemHandleProps } = props;
|
||||
|
||||
const context = useSortableContext(ITEM_HANDLE_NAME);
|
||||
const itemContext = useSortableItemContext(ITEM_HANDLE_NAME);
|
||||
|
||||
const isDisabled = disabled ?? itemContext.disabled;
|
||||
|
||||
const composedRef = useComposedRefs(ref, (node) => {
|
||||
if (!isDisabled) return;
|
||||
itemContext.setActivatorNodeRef(node);
|
||||
});
|
||||
|
||||
const HandlePrimitive = asChild ? Slot : "button";
|
||||
|
||||
return (
|
||||
<HandlePrimitive
|
||||
type="button"
|
||||
aria-controls={itemContext.id}
|
||||
data-disabled={isDisabled}
|
||||
data-dragging={itemContext.isDragging ? "" : undefined}
|
||||
data-slot="sortable-item-handle"
|
||||
{...itemHandleProps}
|
||||
{...(isDisabled ? {} : itemContext.attributes)}
|
||||
{...(isDisabled ? {} : itemContext.listeners)}
|
||||
ref={composedRef}
|
||||
className={cn(
|
||||
"select-none disabled:pointer-events-none disabled:opacity-50",
|
||||
context.flatCursor ? "cursor-default" : "cursor-grab data-dragging:cursor-grabbing",
|
||||
className
|
||||
)}
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const SortableOverlayContext = React.createContext(false);
|
||||
|
||||
const dropAnimation: DropAnimation = {
|
||||
sideEffects: defaultDropAnimationSideEffects({
|
||||
styles: {
|
||||
active: {
|
||||
opacity: "0.4",
|
||||
},
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
interface SortableOverlayProps extends Omit<React.ComponentProps<typeof DragOverlay>, "children"> {
|
||||
container?: Element | DocumentFragment | null;
|
||||
children?: ((params: { value: UniqueIdentifier }) => React.ReactNode) | React.ReactNode;
|
||||
}
|
||||
|
||||
function SortableOverlay(props: SortableOverlayProps) {
|
||||
const { container: containerProp, children, ...overlayProps } = props;
|
||||
|
||||
const context = useSortableContext(OVERLAY_NAME);
|
||||
|
||||
const [mounted, setMounted] = React.useState(false);
|
||||
React.useLayoutEffect(() => setMounted(true), []);
|
||||
|
||||
const container = containerProp ?? (mounted ? globalThis.document?.body : null);
|
||||
|
||||
if (!container) return null;
|
||||
|
||||
return ReactDOM.createPortal(
|
||||
<DragOverlay
|
||||
dropAnimation={dropAnimation}
|
||||
modifiers={context.modifiers}
|
||||
className={cn(!context.flatCursor && "cursor-grabbing")}
|
||||
{...overlayProps}
|
||||
>
|
||||
<SortableOverlayContext.Provider value={true}>
|
||||
{context.activeId
|
||||
? typeof children === "function"
|
||||
? children({ value: context.activeId })
|
||||
: children
|
||||
: null}
|
||||
</SortableOverlayContext.Provider>
|
||||
</DragOverlay>,
|
||||
container
|
||||
);
|
||||
}
|
||||
|
||||
export { SortableRoot as Sortable, SortableContent, SortableItem, SortableItemHandle, SortableOverlay };
|
||||
48
src/components/UI/Tooltip.tsx
Normal file
48
src/components/UI/Tooltip.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
"use client";
|
||||
|
||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function TooltipProvider({ delayDuration = 0, ...props }: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
|
||||
return <TooltipPrimitive.Provider data-slot="tooltip-provider" delayDuration={delayDuration} {...props} />;
|
||||
}
|
||||
|
||||
function Tooltip({ ...props }: React.ComponentProps<typeof TooltipPrimitive.Root>) {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function TooltipTrigger({ ...props }: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
|
||||
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />;
|
||||
}
|
||||
|
||||
function TooltipContent({
|
||||
className,
|
||||
sideOffset = 0,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
|
||||
return (
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Content
|
||||
data-slot="tooltip-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 bg-text text-desktop-primary z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<TooltipPrimitive.Arrow className="bg-text fill-text z-50 size-2.5 translate-y-[calc(-50%-2px)] rotate-45 rounded-xs" />
|
||||
</TooltipPrimitive.Content>
|
||||
</TooltipPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
|
||||
export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger };
|
||||
2
src/data/protectedRoutes.ts
Normal file
2
src/data/protectedRoutes.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
// protected routes
|
||||
export const protectedRoutes = ["/panel", "/complaints/complaints-panel", "/app-complaints"];
|
||||
46
src/data/sidebarMenu.ts
Normal file
46
src/data/sidebarMenu.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
export const menuItems: Menu[] = [
|
||||
{ title: "تصاویر دوربین ها", href: "/road-cameras" },
|
||||
{ title: "شکایات", href: "/complaints" },
|
||||
{
|
||||
title: "اخبار",
|
||||
children: [
|
||||
{ title: "آخرین وضعیت راه های کشور", href: "/news/latest-roads-state" },
|
||||
{ title: "کارگاه های جاده ای", href: "/news/road-works" },
|
||||
{ title: "انسدادها", href: "/news/obstruction-list" },
|
||||
{ title: "محدودیت تردد", href: "/news/limitations" },
|
||||
{ title: "محدودیت اعلامی پلیس", href: "/news/police-traffic-restrictions" },
|
||||
{ title: "اطلاعیه های هواشناسی", href: "/news/road-weather" },
|
||||
],
|
||||
},
|
||||
// {
|
||||
// title: "راه و ترافیک",
|
||||
// children: [
|
||||
// { title: "طبقه بندی راه های کشور", href: "#" },
|
||||
// { title: "داده های تردد شمار", href: "#" },
|
||||
// ],
|
||||
// },
|
||||
];
|
||||
|
||||
export const mobileMenuItems: Menu[] = [
|
||||
{ title: "نقشه", href: "/" },
|
||||
{ title: "تصاویر دوربین ها", href: "/road-cameras" },
|
||||
{ title: "شکایات", href: "/complaints" },
|
||||
{
|
||||
title: "اخبار",
|
||||
children: [
|
||||
{ title: "آخرین وضعیت راه های کشور", href: "/news/latest-roads-state" },
|
||||
{ title: "کارگاه های جاده ای", href: "/news/road-works" },
|
||||
{ title: "انسدادها", href: "/news/obstruction-list" },
|
||||
{ title: "محدودیت تردد", href: "/news/limitations" },
|
||||
{ title: "محدودیت اعلامی پلیس", href: "/news/police-traffic-restrictions" },
|
||||
{ title: "اطلاعیه های هواشناسی", href: "/news/road-weather" },
|
||||
],
|
||||
},
|
||||
// {
|
||||
// title: "راه و ترافیک",
|
||||
// children: [
|
||||
// { title: "طبقه بندی راه های کشور", href: "#" },
|
||||
// { title: "داده های تردد شمار", href: "#" },
|
||||
// ],
|
||||
// },
|
||||
];
|
||||
104
src/hooks/useAuth.ts
Normal file
104
src/hooks/useAuth.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { useAuthStore } from "@/stores/useAuthStore";
|
||||
import useUserStore from "@/stores/userStore";
|
||||
import { POST_LOGOUT, POST_OTP, POST_USER_PASS_LOGIN, POST_VERIFY_OTP_LOGIN_AND_SIGNUP } from "@/utils/apiRoutes";
|
||||
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import useRequest from "./useRequest";
|
||||
|
||||
export function useAuth() {
|
||||
const logoutClient = useUserStore((s) => s.logout);
|
||||
const setIsAuthenticating = useAuthStore((s) => s.setIsAuthenticating);
|
||||
const setTempPhoneNumber = useAuthStore((s) => s.setTempPhoneNumber);
|
||||
const setAuthPanelState = useAuthStore((s) => s.setAuthPanelState);
|
||||
const tempPhoneNumber = useAuthStore((s) => s.tempPhoneNumber);
|
||||
|
||||
const setToken = useUserStore((s) => s.setToken);
|
||||
const setUser = useUserStore((s) => s.changeUser);
|
||||
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const requestServer = useRequest({ auth: true, notification: true });
|
||||
|
||||
async function requestOtp(phone_number: string, aythType: "login" | "signup") {
|
||||
setIsAuthenticating(true);
|
||||
try {
|
||||
await requestServer(POST_OTP, "post", {
|
||||
data: { phone_number },
|
||||
});
|
||||
setTempPhoneNumber(phone_number);
|
||||
setAuthPanelState(aythType === "login" ? "loginOtp" : "signUpOtp");
|
||||
} catch (error) {
|
||||
console.error("OTP request failed:", error);
|
||||
} finally {
|
||||
setIsAuthenticating(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function VerifyOtpLoginAndSignup(otp: string) {
|
||||
if (!tempPhoneNumber) return;
|
||||
|
||||
setIsAuthenticating(true);
|
||||
try {
|
||||
const response = await requestServer(POST_VERIFY_OTP_LOGIN_AND_SIGNUP, "post", {
|
||||
data: {
|
||||
phone_number: tempPhoneNumber,
|
||||
otp,
|
||||
},
|
||||
});
|
||||
const { token, user } = response.data;
|
||||
setToken(token);
|
||||
setUser(user);
|
||||
|
||||
const backUrl = searchParams.get("back_url");
|
||||
if (backUrl) {
|
||||
router.replace(backUrl);
|
||||
} else {
|
||||
router.replace("/");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("OTP verification failed:", error);
|
||||
} finally {
|
||||
setIsAuthenticating(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function loginWithUserName(userName: string, password: string) {
|
||||
setIsAuthenticating(true);
|
||||
try {
|
||||
const response = await requestServer(POST_USER_PASS_LOGIN, "post", {
|
||||
data: {
|
||||
username: userName,
|
||||
password,
|
||||
},
|
||||
});
|
||||
const { token, user } = response.data;
|
||||
setToken(token);
|
||||
setUser(user);
|
||||
} catch (error) {
|
||||
console.error("Login failed:", error);
|
||||
} finally {
|
||||
setIsAuthenticating(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
setIsAuthenticating(true);
|
||||
try {
|
||||
await requestServer(POST_LOGOUT, "post");
|
||||
logoutClient();
|
||||
window.location.reload();
|
||||
} catch (error) {
|
||||
console.error("Logout failed:", error);
|
||||
} finally {
|
||||
setIsAuthenticating(false);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
logout,
|
||||
requestOtp,
|
||||
signIn: VerifyOtpLoginAndSignup,
|
||||
loginWithUserName,
|
||||
};
|
||||
}
|
||||
22
src/hooks/useDebouncedCallback.ts
Normal file
22
src/hooks/useDebouncedCallback.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
|
||||
export function useDebouncedCallback<T extends (...args: never[]) => void>(callback: T, delay: number) {
|
||||
const timer = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
const debounced = useCallback(
|
||||
(...args: Parameters<T>) => {
|
||||
if (timer.current) clearTimeout(timer.current);
|
||||
timer.current = setTimeout(() => callback(...args), delay);
|
||||
},
|
||||
[callback, delay]
|
||||
);
|
||||
|
||||
const cancel = useCallback(() => {
|
||||
if (timer.current) clearTimeout(timer.current);
|
||||
timer.current = null;
|
||||
}, []);
|
||||
|
||||
useEffect(() => cancel, [cancel]);
|
||||
|
||||
return { debounced, cancel };
|
||||
}
|
||||
17
src/hooks/useDevice.ts
Normal file
17
src/hooks/useDevice.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export default function useDevice() {
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const checkIsMobile = () => {
|
||||
setIsMobile(window.innerWidth < 768);
|
||||
};
|
||||
|
||||
checkIsMobile();
|
||||
window.addEventListener("resize", checkIsMobile);
|
||||
return () => window.removeEventListener("resize", checkIsMobile);
|
||||
}, []);
|
||||
|
||||
return isMobile;
|
||||
}
|
||||
92
src/hooks/useRequest.ts
Normal file
92
src/hooks/useRequest.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
"use client";
|
||||
import axios from "axios";
|
||||
import { useTranslations } from "next-intl";
|
||||
import useUserStore from "@/stores/userStore";
|
||||
import ToastStore from "@/stores/useToastStore";
|
||||
import { Notifications } from "@/notifications";
|
||||
|
||||
export interface RequestOptions {
|
||||
auth?: boolean;
|
||||
data?: Record<string, string> | FormData;
|
||||
requestOptions?: {
|
||||
headers?: Record<string, string>;
|
||||
signal?: AbortSignal;
|
||||
[key: string]: any;
|
||||
};
|
||||
notification?: boolean;
|
||||
pending?: boolean;
|
||||
success?: {
|
||||
notification: {
|
||||
show: boolean;
|
||||
};
|
||||
};
|
||||
failed?: {
|
||||
notification: {
|
||||
show: boolean;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
const defaultOptions: RequestOptions = {
|
||||
auth: false,
|
||||
data: {},
|
||||
requestOptions: {
|
||||
headers: {},
|
||||
},
|
||||
notification: true,
|
||||
pending: true,
|
||||
success: {
|
||||
notification: {
|
||||
show: true,
|
||||
},
|
||||
},
|
||||
failed: {
|
||||
notification: {
|
||||
show: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const useRequest = (initOptions: RequestOptions) => {
|
||||
const t = useTranslations();
|
||||
const { token, clearToken } = useUserStore();
|
||||
const { pushToastList, dismissToastList } = ToastStore();
|
||||
|
||||
let _options = { ...defaultOptions, ...initOptions };
|
||||
|
||||
function requestServer(url: string, method: string = "get", options?: RequestOptions) {
|
||||
_options = { ..._options, ...options };
|
||||
|
||||
if (_options.auth) {
|
||||
const isFormData = _options.data instanceof FormData;
|
||||
_options = {
|
||||
..._options,
|
||||
requestOptions: {
|
||||
...(_options.requestOptions || {}),
|
||||
headers: {
|
||||
...(_options.requestOptions?.headers || {}),
|
||||
...(isFormData ? {} : { "Content-Type": "application/json" }),
|
||||
authorization: `Bearer ${token}`,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return Notifications(
|
||||
axios({
|
||||
url,
|
||||
method,
|
||||
data: _options.data,
|
||||
..._options.requestOptions,
|
||||
}),
|
||||
{
|
||||
t,
|
||||
handlers: { pushToastList, dismissToastList, clearToken },
|
||||
options: _options,
|
||||
}
|
||||
);
|
||||
}
|
||||
return requestServer;
|
||||
};
|
||||
|
||||
export default useRequest;
|
||||
12
src/i18n/request.ts
Normal file
12
src/i18n/request.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { cookies } from "next/headers";
|
||||
import { getRequestConfig } from "next-intl/server";
|
||||
|
||||
export default getRequestConfig(async () => {
|
||||
const cookieStore = await cookies();
|
||||
const locale = cookieStore.get("language")?.value || "fa";
|
||||
|
||||
return {
|
||||
locale,
|
||||
messages: (await import(`../../messages/${locale}.json`)).default,
|
||||
};
|
||||
});
|
||||
66
src/lib/compose-refs.ts
Normal file
66
src/lib/compose-refs.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* @see https://github.com/radix-ui/primitives/blob/main/packages/react/compose-refs/src/compose-refs.tsx
|
||||
*/
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
type PossibleRef<T> = React.Ref<T> | undefined;
|
||||
|
||||
/**
|
||||
* Set a given ref to a given value
|
||||
* This utility takes care of different types of refs: callback refs and RefObject(s)
|
||||
*/
|
||||
function setRef<T>(ref: PossibleRef<T>, value: T) {
|
||||
if (typeof ref === "function") {
|
||||
return ref(value);
|
||||
}
|
||||
|
||||
if (ref !== null && ref !== undefined) {
|
||||
ref.current = value;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A utility to compose multiple refs together
|
||||
* Accepts callback refs and RefObject(s)
|
||||
*/
|
||||
function composeRefs<T>(...refs: PossibleRef<T>[]): React.RefCallback<T> {
|
||||
return (node) => {
|
||||
let hasCleanup = false;
|
||||
const cleanups = refs.map((ref) => {
|
||||
const cleanup = setRef(ref, node);
|
||||
if (!hasCleanup && typeof cleanup === "function") {
|
||||
hasCleanup = true;
|
||||
}
|
||||
return cleanup;
|
||||
});
|
||||
|
||||
// React <19 will log an error to the console if a callback ref returns a
|
||||
// value. We don't use ref cleanups internally so this will only happen if a
|
||||
// user's ref callback returns a value, which we only expect if they are
|
||||
// using the cleanup functionality added in React 19.
|
||||
if (hasCleanup) {
|
||||
return () => {
|
||||
for (let i = 0; i < cleanups.length; i++) {
|
||||
const cleanup = cleanups[i];
|
||||
if (typeof cleanup === "function") {
|
||||
cleanup();
|
||||
} else {
|
||||
setRef(refs[i], null);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A custom hook that composes multiple refs
|
||||
* Accepts callback refs and RefObject(s)
|
||||
*/
|
||||
function useComposedRefs<T>(...refs: PossibleRef<T>[]): React.RefCallback<T> {
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: we want to memoize by all values
|
||||
return React.useCallback(composeRefs(...refs), refs);
|
||||
}
|
||||
|
||||
export { composeRefs, useComposedRefs };
|
||||
6
src/lib/utils.ts
Normal file
6
src/lib/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
133
src/notifications/index.tsx
Normal file
133
src/notifications/index.tsx
Normal file
@@ -0,0 +1,133 @@
|
||||
import toast from "react-hot-toast";
|
||||
import { AxiosResponse } from "axios";
|
||||
|
||||
type ToastType = "pending" | "error" | "warning" | "success";
|
||||
type TranslationFunction = (key: string) => string;
|
||||
|
||||
interface ToastHandlers {
|
||||
pushToastList: (type: ToastType, id: string) => void;
|
||||
dismissToastList: (types: ToastType[]) => void;
|
||||
clearToken: () => void;
|
||||
}
|
||||
|
||||
interface RequestOptions {
|
||||
auth?: boolean;
|
||||
data?: Record<string, string> | FormData;
|
||||
requestOptions?: {
|
||||
headers?: Record<string, string>;
|
||||
signal?: AbortSignal;
|
||||
[key: string]: any;
|
||||
};
|
||||
notification?: boolean;
|
||||
pending?: boolean;
|
||||
success?: {
|
||||
notification: {
|
||||
show: boolean;
|
||||
};
|
||||
};
|
||||
failed?: {
|
||||
notification: {
|
||||
show: boolean;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
interface ToastPromiseOptions {
|
||||
t: TranslationFunction;
|
||||
handlers: ToastHandlers;
|
||||
options: RequestOptions;
|
||||
}
|
||||
|
||||
export const Notifications = (promise: Promise<AxiosResponse>, { t, handlers, options }: ToastPromiseOptions) => {
|
||||
const { pushToastList, dismissToastList, clearToken } = handlers;
|
||||
|
||||
const showNotification = options.notification ?? true;
|
||||
const showPending = options.pending ?? true;
|
||||
const showSuccess = options.success?.notification?.show ?? true;
|
||||
const showFailed = options.failed?.notification?.show ?? true;
|
||||
|
||||
if (!showNotification) return promise;
|
||||
|
||||
return toast.promise(
|
||||
promise,
|
||||
{
|
||||
loading: showPending ? t("Notifications.pending") : "",
|
||||
success: (response: AxiosResponse) => {
|
||||
if (showSuccess) {
|
||||
dismissToastList(["pending", "warning", "error", "success"]);
|
||||
pushToastList("success", String(response.status));
|
||||
}
|
||||
return t("Notifications.success");
|
||||
},
|
||||
error: (error: any) => {
|
||||
if (showFailed) {
|
||||
dismissToastList(["pending", "warning", "error", "success"]);
|
||||
}
|
||||
|
||||
if (!showFailed) return t("Notifications.error");
|
||||
|
||||
let message = t("Notifications.error");
|
||||
|
||||
if (error.response) {
|
||||
const status = error.response.status;
|
||||
const responseData = error.response.data;
|
||||
|
||||
// build default message with status code
|
||||
message = `${status} - ${t("Notifications.error")}`;
|
||||
|
||||
if (status >= 500 && status <= 599) {
|
||||
pushToastList("warning", String(status));
|
||||
} else if (status >= 400 && status <= 499) {
|
||||
switch (status) {
|
||||
case 401:
|
||||
clearToken();
|
||||
pushToastList("error", String(status));
|
||||
break;
|
||||
case 422:
|
||||
// append validation messages
|
||||
if (responseData?.message) {
|
||||
if (Array.isArray(responseData.message)) {
|
||||
message = responseData.message.join(", ");
|
||||
} else {
|
||||
message = responseData.message;
|
||||
}
|
||||
}
|
||||
if (responseData?.errors) {
|
||||
const errs: string[] = [];
|
||||
Object.keys(responseData.errors).forEach((key) => {
|
||||
const msg = responseData.errors[key]?.[0];
|
||||
if (msg) errs.push(msg);
|
||||
});
|
||||
if (errs.length) {
|
||||
message = errs.join(", ");
|
||||
}
|
||||
}
|
||||
break;
|
||||
case 429:
|
||||
message = `${status} - Too many requests`;
|
||||
pushToastList("error", String(status));
|
||||
break;
|
||||
default:
|
||||
pushToastList("error", String(status));
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
pushToastList("error", t("Notifications.error"));
|
||||
}
|
||||
} else if (error.request) {
|
||||
message = t("Notifications.error") + " (No response)";
|
||||
pushToastList("error", message);
|
||||
} else {
|
||||
message = t("Notifications.error") + " (Request failed)";
|
||||
pushToastList("error", message);
|
||||
}
|
||||
|
||||
return message;
|
||||
},
|
||||
},
|
||||
{
|
||||
position: "top-center",
|
||||
duration: 4000,
|
||||
}
|
||||
);
|
||||
};
|
||||
40
src/providers/ClientAppProvider.tsx
Normal file
40
src/providers/ClientAppProvider.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
25
src/providers/DeviceProvider.tsx
Normal file
25
src/providers/DeviceProvider.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useState, useEffectEvent } from "react";
|
||||
import { useDeviceStore } from "@/stores/useDeviceStore";
|
||||
import useDevice from "@/hooks/useDevice";
|
||||
|
||||
export default function DeviceProvider({ children }: { children: React.ReactNode }) {
|
||||
const isMobileDevice = useDevice();
|
||||
const setIsMobile = useDeviceStore((state) => state.setIsMobile);
|
||||
|
||||
const [ready, setReady] = useState(false);
|
||||
|
||||
const syncDevice = useEffectEvent((isMobile: boolean) => {
|
||||
setIsMobile(isMobile);
|
||||
setReady(true);
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
syncDevice(isMobileDevice);
|
||||
}, [isMobileDevice]);
|
||||
|
||||
if (!ready) return null;
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
6
src/providers/MapProviderComponent.tsx
Normal file
6
src/providers/MapProviderComponent.tsx
Normal 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
26
src/proxy.ts
Normal 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
100
src/stores/LocationStore.ts
Normal 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
46
src/stores/cityStore.ts
Normal 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;
|
||||
42
src/stores/provinceStore.ts
Normal file
42
src/stores/provinceStore.ts
Normal 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;
|
||||
30
src/stores/useAuthStore.ts
Normal file
30
src/stores/useAuthStore.ts
Normal 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 }),
|
||||
}));
|
||||
22
src/stores/useBottomSheetStore.ts
Normal file
22
src/stores/useBottomSheetStore.ts
Normal 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: {} }),
|
||||
}));
|
||||
11
src/stores/useDeviceStore.ts
Normal file
11
src/stores/useDeviceStore.ts
Normal 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 }),
|
||||
}));
|
||||
37
src/stores/useLanguageStore.ts
Normal file
37
src/stores/useLanguageStore.ts
Normal 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 });
|
||||
}
|
||||
},
|
||||
}));
|
||||
16
src/stores/useModalStore.ts
Normal file
16
src/stores/useModalStore.ts
Normal 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 }),
|
||||
}));
|
||||
51
src/stores/useToastStore.ts
Normal file
51
src/stores/useToastStore.ts
Normal 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
157
src/stores/userStore.ts
Normal 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
116
src/styles/globals.css
Normal 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;
|
||||
}
|
||||
6
src/types/language.types.ts
Normal file
6
src/types/language.types.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export interface Language {
|
||||
id: number;
|
||||
key: string;
|
||||
language: string;
|
||||
direction: "rtl" | "ltr";
|
||||
}
|
||||
5
src/types/sidebar-menu.types.ts
Normal file
5
src/types/sidebar-menu.types.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
type Menu = {
|
||||
title: string;
|
||||
href?: string;
|
||||
children?: Menu[];
|
||||
};
|
||||
4
src/types/types.ts
Normal file
4
src/types/types.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export interface LatLng {
|
||||
lat: number;
|
||||
lng: number;
|
||||
}
|
||||
16
src/utils/ToastProvider.tsx
Normal file
16
src/utils/ToastProvider.tsx
Normal 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 />;
|
||||
}
|
||||
15
src/utils/UserInitializer.tsx
Normal file
15
src/utils/UserInitializer.tsx
Normal 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
29
src/utils/WithTooltip.tsx
Normal 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
15
src/utils/apiRoutes.ts
Normal 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
63
src/utils/locateUser.ts
Normal 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
58
src/utils/useMapMove.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user