header + navigation and menu
This commit is contained in:
+155
-12
@@ -1,26 +1,169 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
|
||||
:root {
|
||||
--background: #ffffff;
|
||||
--foreground: #171717;
|
||||
}
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-geist-sans);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-ring: var(--ring);
|
||||
--color-input: var(--input);
|
||||
--color-border: var(--border);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-card: var(--card);
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--background: #0a0a0a;
|
||||
--foreground: #ededed;
|
||||
}
|
||||
:root {
|
||||
--radius: 0.625rem;
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(0.205 0 0);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
--accent: oklch(0.97 0 0);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.708 0 0);
|
||||
--chart-1: oklch(0.646 0.222 41.116);
|
||||
--chart-2: oklch(0.6 0.118 184.704);
|
||||
--chart-3: oklch(0.398 0.07 227.392);
|
||||
--chart-4: oklch(0.828 0.189 84.429);
|
||||
--chart-5: oklch(0.769 0.188 70.08);
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
--sidebar-primary: oklch(0.205 0 0);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.97 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.145 0 0);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.205 0 0);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.205 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.922 0 0);
|
||||
--primary-foreground: oklch(0.205 0 0);
|
||||
--secondary: oklch(0.269 0 0);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: oklch(0.269 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.556 0 0);
|
||||
--chart-1: oklch(0.488 0.243 264.376);
|
||||
--chart-2: oklch(0.696 0.17 162.48);
|
||||
--chart-3: oklch(0.769 0.188 70.08);
|
||||
--chart-4: oklch(0.627 0.265 303.9);
|
||||
--chart-5: oklch(0.645 0.246 16.439);
|
||||
--sidebar: oklch(0.205 0 0);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.269 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.556 0 0);
|
||||
}
|
||||
@theme {
|
||||
--color-border: #e5e5e5;
|
||||
--color-primary: #015699;
|
||||
}
|
||||
|
||||
html {
|
||||
direction: rtl;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
font-family: IRANSans;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
direction: rtl;
|
||||
background: #fff;
|
||||
font-feature-settings: "lnum"; /* اطمینان از نمایش اعداد انگلیسی */
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
min-height: 100% !important;
|
||||
height: 100%;
|
||||
}
|
||||
* {
|
||||
outline: none;
|
||||
}
|
||||
input::placeholder,
|
||||
textarea::placeholder {
|
||||
color: #888888;
|
||||
}
|
||||
|
||||
input.place-black::placeholder {
|
||||
color: black;
|
||||
}
|
||||
|
||||
textarea.place-black::placeholder {
|
||||
color: black;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
|
||||
+6
-15
@@ -1,16 +1,7 @@
|
||||
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"],
|
||||
});
|
||||
import "../../public/css/fontiran.css";
|
||||
import QueryProvider from "@/config/QueryProvider";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Create Next App",
|
||||
@@ -24,10 +15,10 @@ export default function RootLayout({
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||
>
|
||||
{children}
|
||||
<body>
|
||||
<QueryProvider>
|
||||
{children}
|
||||
</QueryProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
+7
-100
@@ -1,103 +1,10 @@
|
||||
import Image from "next/image";
|
||||
import { NextPage } from 'next'
|
||||
import withLayout from '@/hoc/withLayout'
|
||||
|
||||
export default function Home() {
|
||||
const Home: NextPage = () => {
|
||||
return (
|
||||
<div className="font-sans grid grid-rows-[20px_1fr_20px] items-center justify-items-center min-h-screen p-8 pb-20 gap-16 sm:p-20">
|
||||
<main className="flex flex-col gap-[32px] row-start-2 items-center sm:items-start">
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/next.svg"
|
||||
alt="Next.js logo"
|
||||
width={180}
|
||||
height={38}
|
||||
priority
|
||||
/>
|
||||
<ol className="font-mono list-inside list-decimal text-sm/6 text-center sm:text-left">
|
||||
<li className="mb-2 tracking-[-.01em]">
|
||||
Get started by editing{" "}
|
||||
<code className="bg-black/[.05] dark:bg-white/[.06] font-mono font-semibold px-1 py-0.5 rounded">
|
||||
src/app/page.tsx
|
||||
</code>
|
||||
.
|
||||
</li>
|
||||
<li className="tracking-[-.01em]">
|
||||
Save and see your changes instantly.
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
<div className="flex gap-4 items-center flex-col sm:flex-row">
|
||||
<a
|
||||
className="rounded-full border border-solid border-transparent transition-colors flex items-center justify-center bg-foreground text-background gap-2 hover:bg-[#383838] dark:hover:bg-[#ccc] font-medium text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 sm:w-auto"
|
||||
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={20}
|
||||
height={20}
|
||||
/>
|
||||
Deploy now
|
||||
</a>
|
||||
<a
|
||||
className="rounded-full border border-solid border-black/[.08] dark:border-white/[.145] transition-colors flex items-center justify-center hover:bg-[#f2f2f2] dark:hover:bg-[#1a1a1a] hover:border-transparent font-medium text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 w-full sm:w-auto 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"
|
||||
>
|
||||
Read our docs
|
||||
</a>
|
||||
</div>
|
||||
</main>
|
||||
<footer className="row-start-3 flex gap-[24px] flex-wrap items-center justify-center">
|
||||
<a
|
||||
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
|
||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
aria-hidden
|
||||
src="/file.svg"
|
||||
alt="File icon"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
Learn
|
||||
</a>
|
||||
<a
|
||||
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
|
||||
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
aria-hidden
|
||||
src="/window.svg"
|
||||
alt="Window icon"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
Examples
|
||||
</a>
|
||||
<a
|
||||
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
|
||||
href="https://nextjs.org?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
aria-hidden
|
||||
src="/globe.svg"
|
||||
alt="Globe icon"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
Go to nextjs.org →
|
||||
</a>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
<div></div>
|
||||
)
|
||||
}
|
||||
|
||||
export default withLayout(Home)
|
||||
@@ -0,0 +1,15 @@
|
||||
import { FC } from 'react'
|
||||
|
||||
type Props = {
|
||||
errorText: string
|
||||
}
|
||||
|
||||
const Error: FC<Props> = (props: Props) => {
|
||||
return (
|
||||
<div className='mt-1.5 font-normal text-red-500 text-[11px]'>
|
||||
{props.errorText}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Error
|
||||
@@ -0,0 +1,109 @@
|
||||
'use client'
|
||||
import { FC, InputHTMLAttributes, useEffect, useState } from 'react'
|
||||
import { clx } from '../helpers/utils';
|
||||
import { SearchNormal } from 'iconsax-react';
|
||||
import Error from './Error';
|
||||
|
||||
type Variant = "floating_outlined" | "primary" | "search";
|
||||
|
||||
type Props = {
|
||||
label?: string;
|
||||
className?: string;
|
||||
variant?: Variant;
|
||||
error_text?: string;
|
||||
onEnter?: () => void;
|
||||
unit?: string;
|
||||
seprator?: boolean;
|
||||
isNotRequired?: boolean;
|
||||
onChangeSearchFinal?: (value: string) => void;
|
||||
} & InputHTMLAttributes<HTMLInputElement>
|
||||
|
||||
const formatNumber = (value: string | number): string => {
|
||||
if (!value) return '';
|
||||
const inputValue = String(value).replace(/,/g, '');
|
||||
return inputValue.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',');
|
||||
};
|
||||
|
||||
const Input: FC<Props> = (props: Props) => {
|
||||
|
||||
const [formattedValue, setFormattedValue] = useState<string>(
|
||||
props.value ? formatNumber(props.value as string) : ''
|
||||
);
|
||||
|
||||
const [search, setSearch] = useState<string>('')
|
||||
|
||||
const inputClass = clx(
|
||||
'w-full h-10 text-black block px-4 text-xs rounded-xl border border-[#D0D0D0]',
|
||||
props.readOnly && 'bg-gray-100 border-0 text-description',
|
||||
props.variant === 'search' && 'bg-[#EEF0F7] border-0 pr-10',
|
||||
props.className
|
||||
);
|
||||
|
||||
const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (props.seprator) {
|
||||
const inputValue = event.target.value.replace(/,/g, ''); // حذف کاماها
|
||||
const formatted = formatNumber(inputValue);
|
||||
|
||||
// بهروزرسانی مقدار قالببندیشده
|
||||
setFormattedValue(formatted);
|
||||
|
||||
// ارسال مقدار خام به `onChange` والد
|
||||
props.onChange?.({
|
||||
...event,
|
||||
target: {
|
||||
...event.target,
|
||||
value: inputValue,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
props.onChange?.(event);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (props.value) {
|
||||
setFormattedValue(formatNumber(props.value as string));
|
||||
} else {
|
||||
setFormattedValue('');
|
||||
}
|
||||
}, [props.value])
|
||||
|
||||
useEffect(() => {
|
||||
if (props.variant === 'search' && props.onChangeSearchFinal) {
|
||||
const timeout = setTimeout(() => {
|
||||
props.onChangeSearchFinal?.(search)
|
||||
}, 1000)
|
||||
return () => clearTimeout(timeout)
|
||||
}
|
||||
}, [search, props])
|
||||
|
||||
|
||||
return (
|
||||
<div className='w-full'>
|
||||
<label className='text-sm text-black'>
|
||||
{props.label}
|
||||
</label>
|
||||
|
||||
<div className='w-full relative mt-1'>
|
||||
<input {...props} onChange={(e) => {
|
||||
setSearch(e.target.value)
|
||||
handleInputChange(e)
|
||||
}} value={props.seprator ? formattedValue : props.value} type={props.type} className={inputClass} />
|
||||
|
||||
{
|
||||
props.variant === 'search' &&
|
||||
<SearchNormal size={20} color='#8C90A3' className='absolute top-0 w-5 bottom-0 my-auto right-3' />
|
||||
}
|
||||
|
||||
{
|
||||
props.error_text &&
|
||||
<Error
|
||||
errorText={props.error_text}
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Input
|
||||
@@ -0,0 +1,28 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as SeparatorPrimitive from "@radix-ui/react-separator"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Separator({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
decorative = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
|
||||
return (
|
||||
<SeparatorPrimitive.Root
|
||||
data-slot="separator"
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Separator }
|
||||
@@ -0,0 +1,28 @@
|
||||
import axios from "axios";
|
||||
import { getToken } from "./func";
|
||||
import { BASE_URL } from "./const";
|
||||
// import { Pages } from "./Pages";
|
||||
|
||||
const axiosInstance = axios.create({
|
||||
// baseURL: process.env.NEXT_PUBLIC_BASE_URL,
|
||||
baseURL: BASE_URL,
|
||||
});
|
||||
|
||||
axiosInstance.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
axiosInstance.interceptors.request.use(async function (config) {
|
||||
const tokenValue = await getToken();
|
||||
const token = tokenValue ? tokenValue : "";
|
||||
|
||||
if (token) {
|
||||
config.headers.Authorization = "Bearer " + token;
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
export default axiosInstance;
|
||||
@@ -1,2 +1,3 @@
|
||||
export const TOKEN_NAME = "sh_token";
|
||||
export const REFRESH_TOKEN_NAME = "sh_refresh_token";
|
||||
export const BASE_URL = "https://api.shinan.ir";
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
type Without<T, U> = { [P in Exclude<keyof T, keyof U>]?: never };
|
||||
|
||||
export type XOR<T, U> = T | U extends object
|
||||
? (Without<T, U> & U) | (Without<U, T> & T)
|
||||
: T | U;
|
||||
|
||||
export type ItemsSelectType = {
|
||||
value: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
export type ErrorType = Error & {
|
||||
response?: {
|
||||
data?: {
|
||||
error: {
|
||||
message: string[];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,11 @@
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
/**
|
||||
* Concatenates truthy classes into a space-separated string.
|
||||
*
|
||||
* @param classes - The classes to concatenate.
|
||||
* @returns The concatenated classes.
|
||||
*/
|
||||
export const clx = (...classes: (string | boolean | undefined)[]): string => {
|
||||
return twMerge(classes.filter(Boolean).join(" "));
|
||||
};
|
||||
@@ -0,0 +1,38 @@
|
||||
|
||||
import Header from '@/share/Header'
|
||||
import { FC, ComponentType } from 'react'
|
||||
// import Header from '@/share/Header'
|
||||
|
||||
interface LayoutProps {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
const Layout: FC<LayoutProps> = ({ children }) => {
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col">
|
||||
<Header />
|
||||
<main className="flex-1">
|
||||
{children}
|
||||
</main>
|
||||
{/* <Footer /> */}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// HOC function
|
||||
function withLayout<P extends object>(WrappedComponent: ComponentType<P>) {
|
||||
const WithLayoutComponent: FC<P> = (props) => {
|
||||
return (
|
||||
<Layout>
|
||||
<WrappedComponent {...props} />
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
|
||||
WithLayoutComponent.displayName = `withLayout(${WrappedComponent.displayName || WrappedComponent.name})`
|
||||
|
||||
return WithLayoutComponent
|
||||
}
|
||||
|
||||
export default withLayout
|
||||
export { Layout }
|
||||
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import Input from '@/components/Input'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { DocumentText, Home, Profile, ShoppingCart } from 'iconsax-react'
|
||||
import { FC } from 'react'
|
||||
import Menu from './components/Menu'
|
||||
|
||||
const Header: FC = () => {
|
||||
return (
|
||||
<div className='fixed top-0 bg-white w-full'>
|
||||
<div className='flex items-center justify-between px-6 py-[18px]'>
|
||||
<div className='flex gap-[30px] items-center'>
|
||||
<div>LOGO</div>
|
||||
<Input className='w-[500px]' variant='search' placeholder='جستجو' />
|
||||
</div>
|
||||
|
||||
<div className='flex items-center gap-6'>
|
||||
<div className='h-10 border border-border py-3 px-4 rounded-[12px] flex items-center gap-2.5'>
|
||||
<Profile size={20} color='#333333' />
|
||||
<div className='text-sm text-[#333333]'>حساب کاربری</div>
|
||||
</div>
|
||||
|
||||
<Separator style={{ height: 25 }} orientation='vertical' />
|
||||
|
||||
<div className='h-10 p-3 rounded-[12px] border border-border flex items-center gap-2.5'>
|
||||
<ShoppingCart size={20} color='#333333' />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className='mt-7 text-sm px-6 flex items-center gap-7'>
|
||||
<Menu />
|
||||
<Separator style={{ height: 20 }} orientation='vertical' />
|
||||
|
||||
<div className='flex items-center gap-2.5'>
|
||||
<Home size={20} color='#333333' />
|
||||
<div className='text-[#333333]'>صفحه نخست</div>
|
||||
</div>
|
||||
<div className='flex items-center gap-2.5'>
|
||||
<DocumentText size={20} color='#333333' />
|
||||
<div className='text-[#333333]'>مجله</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Header
|
||||
@@ -0,0 +1,117 @@
|
||||
'use client'
|
||||
import { HambergerMenu, ArrowLeft2 } from 'iconsax-react'
|
||||
import { FC, useState, useRef, useEffect } from 'react'
|
||||
import Image from 'next/image'
|
||||
import { useCategories } from '../hooks/useShareData'
|
||||
import { Category } from '../types/SharedTypes'
|
||||
|
||||
const Menu: FC = () => {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [hoveredPath, setHoveredPath] = useState<Category[]>([])
|
||||
const { data, isLoading } = useCategories()
|
||||
const menuRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
|
||||
setIsOpen(false)
|
||||
setHoveredPath([])
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||
}, [])
|
||||
|
||||
const parentCategories = data?.results?.data?.filter(cat => !cat.parent) || []
|
||||
|
||||
const handleCategoryHover = (category: Category, level: number) => {
|
||||
const newPath = [...hoveredPath.slice(0, level), category]
|
||||
setHoveredPath(newPath)
|
||||
}
|
||||
|
||||
const renderCategoryLevel = (categories: Category[], level: number) => {
|
||||
return (
|
||||
<div className='w-64 border-l border-gray-200 last:border-l-0 flex flex-col min-h-0'>
|
||||
<div className='p-2 overflow-y-auto max-h-[calc(100vh-200px)]'>
|
||||
{categories.map((category) => (
|
||||
<div
|
||||
key={category._id}
|
||||
className={`p-3 rounded-lg cursor-pointer transition-colors hover:bg-gray-50 ${hoveredPath[level]?._id === category._id ? 'bg-gray-50' : ''
|
||||
}`}
|
||||
onMouseEnter={() => handleCategoryHover(category, level)}
|
||||
>
|
||||
<div className='flex items-center gap-3'>
|
||||
{category.icon && (
|
||||
<Image
|
||||
src={category.icon}
|
||||
alt={category.title_fa}
|
||||
width={20}
|
||||
height={20}
|
||||
className='w-5 h-5 object-contain'
|
||||
/>
|
||||
)}
|
||||
<span className='text-sm text-gray-800 flex-1'>{category.title_fa}</span>
|
||||
{category.children?.length > 0 && (
|
||||
<ArrowLeft2 size={16} color='#666' />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='relative' ref={menuRef}>
|
||||
<div
|
||||
className='flex items-center gap-2.5 cursor-pointer'
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
onMouseEnter={() => setIsOpen(true)}
|
||||
>
|
||||
<HambergerMenu size={24} color='#333333' />
|
||||
<div className='text-[#333333]'>دسته بندی کالاها</div>
|
||||
</div>
|
||||
|
||||
{isOpen && (
|
||||
<div
|
||||
className='absolute top-full right-0 mt-2 bg-white rounded-xl shadow-lg border border-gray-200 z-50 flex max-w-[90vw] max-h-screen overflow-hidden'
|
||||
onMouseLeave={() => {
|
||||
setIsOpen(false)
|
||||
setHoveredPath([])
|
||||
}}
|
||||
>
|
||||
{/* دستههای اصلی (سطح اول) */}
|
||||
{isLoading ? (
|
||||
<div className='w-64 border-l border-gray-200 flex items-center justify-center h-40'>
|
||||
<div className='text-gray-500'>در حال بارگذاری...</div>
|
||||
</div>
|
||||
) : parentCategories.length > 0 ? (
|
||||
<>
|
||||
{renderCategoryLevel(parentCategories, 0)}
|
||||
|
||||
{/* سطوح بعدی (recursive) */}
|
||||
{hoveredPath.map((category, index) => {
|
||||
if (category.children && category.children.length > 0) {
|
||||
return (
|
||||
<div key={`level-${index + 1}-${category._id}`}>
|
||||
{renderCategoryLevel(category.children, index + 1)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return null
|
||||
})}
|
||||
</>
|
||||
) : (
|
||||
<div className='w-64 border-l border-gray-200 flex items-center justify-center h-40'>
|
||||
<div className='text-gray-500'>دستهبندیای یافت نشد</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Menu
|
||||
@@ -0,0 +1,9 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { getCategories } from "../service/ShareService";
|
||||
|
||||
export const useCategories = () => {
|
||||
return useQuery({
|
||||
queryKey: ["categories"],
|
||||
queryFn: getCategories,
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
import axios from "@/config/axios";
|
||||
import { CategoriesResponseType } from "../types/SharedTypes";
|
||||
|
||||
export const getCategories = async () => {
|
||||
const { data } = await axios.get<CategoriesResponseType>("/category");
|
||||
return data;
|
||||
};
|
||||
@@ -7,11 +7,42 @@ export type SharedStoreType = {
|
||||
};
|
||||
|
||||
export interface ApiResponse<T> {
|
||||
statusCode: number;
|
||||
status: number;
|
||||
success: boolean;
|
||||
data: T;
|
||||
results: T;
|
||||
}
|
||||
|
||||
export type Category = {
|
||||
_id: string;
|
||||
title_fa: string;
|
||||
title_en: string;
|
||||
icon: string;
|
||||
imageUrl: string;
|
||||
description: string;
|
||||
variants: number[];
|
||||
hierarchy: string[];
|
||||
leaf: boolean;
|
||||
parent: string | null;
|
||||
deleted: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
url: string;
|
||||
children: Category[];
|
||||
theme?: string;
|
||||
};
|
||||
|
||||
export type CategoriesResponseType = ApiResponse<{
|
||||
data: Category[];
|
||||
}>;
|
||||
|
||||
export type CategoryResponseType = ApiResponse<{
|
||||
id: string;
|
||||
name: string;
|
||||
parentId: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}>;
|
||||
|
||||
export type UserMeResponseType = ApiResponse<{
|
||||
user: {
|
||||
id: string;
|
||||
@@ -31,7 +62,6 @@ export type UserMeResponseType = ApiResponse<{
|
||||
id: string;
|
||||
createdAt: string;
|
||||
name: string;
|
||||
// Add other role properties as needed
|
||||
}>;
|
||||
};
|
||||
}>;
|
||||
|
||||
Reference in New Issue
Block a user