add: menu list

improve: ux
This commit is contained in:
Mahyar Khanbolooki
2025-07-07 20:32:17 +03:30
parent 1a6a7ef044
commit 7fd549b866
14 changed files with 459 additions and 37 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 143 KiB

+156 -5
View File
@@ -1,10 +1,16 @@
'use client'; 'use client';
import { useCallback, useState } from "react"; import { Fragment, useCallback, useState } from "react";
import SearchBox from "@/components/input/SearchBox"; import SearchBox from "@/components/input/SearchBox";
import CategoryItemRenderer from "@/components/listview/CategoryItemRenderer"; import CategoryItemRenderer from "@/components/listview/CategoryItemRenderer";
import HorizontalScrollView from "@/components/listview/HorizontalScrollView"; import HorizontalScrollView from "@/components/listview/HorizontalScrollView";
import Image from "next/image"; import Image from "next/image";
import EqualizerIcon from "@/components/icons/EqualizerIcon";
import TextAlignIcon from "@/components/icons/TextAlignIcon";
import PlusIcon from "@/components/icons/PlusIcon";
import MinusIcon from "@/components/icons/MinusIcon";
import VerticalScrollView from "@/components/listview/VerticalScrollView";
import MenuItemRenderer from "@/components/listview/MenuItemRenderer";
const categories = [ const categories = [
{ {
@@ -48,6 +54,93 @@ const categories = [
} }
] ]
const foods = [
{
category: 0,
price: '69.000',
name: 'کباب چوبی ژیوان )سیخ چوبی(',
contains: 'فیله گوساله / سس سفید / قارچ و سیب زمینی/ پنیر پارمزان'
},
{
category: 1,
price: '49.000',
name: 'برگر گوشت گوساله',
contains: 'گوشت گوساله / پنیر چدار / کاهو / گوجه'
},
{
category: 2,
price: '29.000',
name: 'ساندویچ مرغ',
contains: 'مرغ / کاهو / گوجه / سس مایونز'
},
{
category: 0,
price: '89.000',
name: 'کباب ماهی',
contains: 'ماهی / سس لیمو / قارچ و سیب زمینی'
},
{
category: 1,
price: '39.000',
name: 'سالاد فصل',
contains: 'کاهو / گوجه / خیار / پنیر فتا'
},
{
category: 3,
price: '59.000',
name: 'پاستا گوشت',
contains: 'گوشت گوساله / پاستا / سس گوجه'
},
{
category: 2,
price: '49.000',
name: 'ساندویچ تن ماهی',
contains: 'تن ماهی / کاهو / گوجه / سس مایونز'
},
{
category: 0,
price: '79.000',
name: 'کباب گوشت گوسفندی',
contains: 'گوشت گوسفندی / سس سفید / قارچ و سیب زمینی'
},
{
category: 1,
price: '29.000',
name: 'برگر مرغ',
contains: 'مرغ / پنیر چدار / کاهو / گوجه'
},
{
category: 3,
price: '69.000',
name: 'سالاد میگو',
contains: 'میگو / کاهو / گوجه / خیار'
},
{
category: 2,
price: '39.000',
name: 'ساندویچ شاورما',
contains: 'مرغ / کاهو / گوجه / سس تارتار'
},
{
category: 0,
price: '99.000',
name: 'کباب گوشت بوقلمون',
contains: 'گوشت بوقلمون / سس سفید / قارچ و سیب زمینی'
},
{
category: 1,
price: '59.000',
name: 'برگر ماهی',
contains: 'ماهی / پنیر چدار / کاهو / گوجه'
},
{
category: 3,
price: '49.000',
name: 'سالاد مرغ',
contains: 'مرغ / کاهو / گوجه / خیار'
},
];
export default function MenuIndex() { export default function MenuIndex() {
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [selectedCategory, setSelectedCategory] = useState(0); const [selectedCategory, setSelectedCategory] = useState(0);
@@ -64,24 +157,82 @@ export default function MenuIndex() {
return ( return (
<div className="flex flex-col gap-4 pt-4 items-center"> <div className="flex flex-col gap-4 pt-4 items-center">
<SearchBox value={search} onChange={updateSearch} /> <SearchBox value={search} onChange={updateSearch} />
<HorizontalScrollView className="w-full not-sm:noscrollbar py-4!" itemRenderer={(child, index) => { <HorizontalScrollView className="w-full noscrollbar py-4!" itemRenderer={(child, index) => {
return <CategoryItemRenderer className={`${index === selectedCategory && 'bg-white!'}`} onClick={() => updateCategory(index)} key={index}>{child}</CategoryItemRenderer> return <CategoryItemRenderer className={`${index === selectedCategory && 'bg-white!'}`} onClick={() => updateCategory(index)} key={index}>{child}</CategoryItemRenderer>
}}> }}>
{categories.map((item, index) => { {categories.map((item, index) => {
return ( return (
<div key={index}> <div key={index}>
<Image <Image
priority priority
src={'/assets/images/food-image.png'} src={'/assets/images/food-image.png'}
width={32} width={32}
height={32} height={32}
alt="category image" alt="category image"
/> />
<span className="text-xs text-black">{item.title}</span> <span className="text-xs text-black">{item.title}</span>
</div> </div>
) )
})} })}
</HorizontalScrollView> </HorizontalScrollView>
<section className="w-full">
<div className="flex justify-between items-center">
<span className="text-base font-semibold">{categories[selectedCategory].title}</span>
<div className="inline-flex gap-2 justify-around items-center">
<button className="rounded-xl h-8 bg-white ps-4 pe-2 py-1.5 inline-flex items-center justify-between gap-[7px]">
<EqualizerIcon />
<span className="text-xs leading-5">فیلتر بر اساس</span>
</button>
<button className="rounded-lg h-8 bg-white p-1.5">
<TextAlignIcon />
</button>
</div>
</div>
<VerticalScrollView
className="mt-5! overflow-y-auto h-full"
itemRenderer={(child, index) => {
return (
<MenuItemRenderer key={index}>{child}</MenuItemRenderer>
)
}}
>
{foods.map((food, index) => {
return (
<Fragment key={index}>
<Image
className="rounded-xl w-28 h-28"
src={'/assets/images/food-preview.png'}
height={100}
width={100}
alt="Food image"
/>
<div className="w-full h-full inline-flex flex-col justify-between">
<div>
<div className="text-sm2 font-semibold">{food.name}</div>
<div className="text-[#7F7F7F] text-xs leading-5 font-normal mt-2">{food.contains}</div>
</div>
<div className="inline-flex justify-between w-full items-end">
<span className="w-full" dir="ltr">{food.price} T</span>
<div className="bg-background max-w-[115px] rounded-lg w-full h-8 inline-flex py-1 px-[5px] justify-between items-center gap-2">
{/* <PlusIcon />
<div className="text-sm2 pt-0.5 font-semibold" style={{verticalAlign: 'end'}}>افزودن</div> */}
<PlusIcon className="bg-white rounded-sm p-2" size={26} />
<div className="text-sm2 pt-0.5 font-semibold" style={{ verticalAlign: 'end' }}>2</div>
<MinusIcon className="bg-white rounded-sm p-2" size={26} />
</div>
</div>
</div>
</Fragment>
)
})}
</VerticalScrollView>
</section>
</div> </div>
); );
} }
+77
View File
@@ -0,0 +1,77 @@
import React from 'react';
interface EqualizerIconProps {
className?: string;
stroke?: string;
strokeWidth?: number;
size?: number;
}
const EqualizerIcon: React.FC<EqualizerIconProps> = ({
className = '',
stroke = '#333333',
strokeWidth = 1.5,
size = 16,
}) => {
return (
<svg
width={size}
height={size}
viewBox="0 0 16 16"
fill="none"
className={className}
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M14.6667 11.666H10"
stroke={stroke}
strokeWidth={strokeWidth}
strokeMiterlimit="10"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M3.3335 11.666H1.3335"
stroke={stroke}
strokeWidth={strokeWidth}
strokeMiterlimit="10"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M14.6665 4.33398H12.6665"
stroke={stroke}
strokeWidth={strokeWidth}
strokeMiterlimit="10"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M6.00016 4.33398H1.3335"
stroke={stroke}
strokeWidth={strokeWidth}
strokeMiterlimit="10"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M4.66683 9.66602H8.66683C9.40016 9.66602 10.0002 9.99935 10.0002 10.9993V12.3327C10.0002 13.3327 9.40016 13.666 8.66683 13.666H4.66683C3.9335 13.666 3.3335 13.3327 3.3335 12.3327V10.9993C3.3335 9.99935 3.9335 9.66602 4.66683 9.66602Z"
stroke={stroke}
strokeWidth={strokeWidth}
strokeMiterlimit="10"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M7.33333 2.33398H11.3333C12.0667 2.33398 12.6667 2.66732 12.6667 3.66732V5.00065C12.6667 6.00065 12.0667 6.33398 11.3333 6.33398H7.33333C6.6 6.33398 6 6.00065 6 5.00065V3.66732C6 2.66732 6.6 2.33398 7.33333 2.33398Z"
stroke={stroke}
strokeWidth={strokeWidth}
strokeMiterlimit="10"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
};
export default EqualizerIcon;
+35
View File
@@ -0,0 +1,35 @@
import React from 'react';
interface MinusIconProps {
className?: string;
stroke?: string;
strokeWidth?: number;
size?: number;
}
const MinusIcon: React.FC<MinusIconProps> = ({
className = '',
stroke = '#333333',
strokeWidth = 1.5,
size = 10,
}) => {
return (
<svg
width={size}
height={size}
viewBox="0 0 10 2"
fill="none"
className={className}
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M9.23145 1L1.23145 0.999999"
stroke={stroke}
strokeWidth={strokeWidth}
strokeLinecap="round"
/>
</svg>
);
};
export default MinusIcon;
+41
View File
@@ -0,0 +1,41 @@
import React from 'react';
interface PlusIconProps {
className?: string;
stroke?: string;
strokeWidth?: number;
size?: number;
}
const PlusIcon: React.FC<PlusIconProps> = ({
className = '',
stroke = '#333333',
strokeWidth = 1.5,
size = 10,
}) => {
return (
<svg
width={size}
height={size}
viewBox="0 0 10 10"
fill="none"
className={className}
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M5 1L5 9"
stroke={stroke}
strokeWidth={strokeWidth}
strokeLinecap="round"
/>
<path
d="M9 5L1 5"
stroke={stroke}
strokeWidth={strokeWidth}
strokeLinecap="round"
/>
</svg>
);
};
export default PlusIcon;
+66
View File
@@ -0,0 +1,66 @@
import React from 'react';
interface TextAlignIconProps {
className?: string;
stroke?: string;
strokeWidth?: number;
size?: number;
}
const TextAlignIcon: React.FC<TextAlignIconProps> = ({
className = '',
stroke = '#333333',
strokeWidth = 1.5,
size = 20,
}) => {
return (
<svg
width={size}
height={size}
viewBox="0 0 20 20"
fill="none"
className={className}
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M10 14.1672L1.66667 14.1672"
stroke={stroke}
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M10 10H1.66667"
stroke={stroke}
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M10 5.83374H1.66667"
stroke={stroke}
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M18.3335 10.8335L15.8335 14.1668L13.3335 10.8335"
stroke={stroke}
strokeWidth={strokeWidth}
strokeMiterlimit="10"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M15.8335 5.8335L15.8335 14.1668"
stroke={stroke}
strokeWidth={strokeWidth}
strokeMiterlimit="10"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
};
export default TextAlignIcon;
@@ -0,0 +1,15 @@
import React from 'react';
type Props = {
children: React.ReactNode;
} & React.HTMLAttributes<HTMLDivElement>;
function MenuItemRenderer({ children, ...rest }: Props) {
return (
<div {...rest} className={`${rest.className} transition-all duration-200 ease-out w-full h-36 bg-white rounded-3xl px-4 pt-3.5 pb-3 flex justify-between gap-4`}>
{children}
</div>
);
}
export default MenuItemRenderer;
@@ -0,0 +1,34 @@
import React, { ReactElement, ReactNode } from 'react';
type Props = {
itemRenderer?: (child: ReactNode, index: number) => ReactElement;
children: ReactNode;
} & React.OlHTMLAttributes<HTMLUListElement>;
function VerticalScrollView({ itemRenderer, children, ...restProps }: Props) {
const items = React.Children.toArray(children);
return (
<ul
{...restProps}
style={{
display: 'flex',
flexDirection: 'column',
overflowY: 'auto',
gap: '16px',
padding: 0,
margin: 0,
listStyle: 'none',
...restProps.style,
}}
>
{items.map((child, index) => (
<li key={index} style={{ flex: '0 0 auto' }}>
{itemRenderer ? itemRenderer(child, index) : child}
</li>
))}
</ul>
);
}
export default VerticalScrollView;
+27 -25
View File
@@ -75,32 +75,34 @@ function Menu({ menuState, toggleMenuState, nightModeState, togglenightModeState
const menuStateMemo = useMemo(() => menuState, [menuState]); const menuStateMemo = useMemo(() => menuState, [menuState]);
return ( return (
<BlurredOverlayContainer bgOpacity='40' visible={menuStateMemo} changeDelay='300'> <div className='w-full h-dvh'>
<div className='w-full h-full' onClick={toggleMenuState}></div> <BlurredOverlayContainer bgOpacity='40' visible={menuStateMemo} changeDelay='300'>
<div <div className='w-full h-dvh overflow-hidden' onClick={toggleMenuState}></div>
data-isvisible={menuStateMemo} <div
onClick={(e) => { e.stopPropagation(); }} data-isvisible={menuStateMemo}
className={`absolute top-0 -right-full data-[isvisible=true]:right-0 transition-all duration-300 ease-in-out not-xl:!rounded-s-none overflow-clip bg-white w-[200px] h-full flex flex-col z-40`}> onClick={(e) => { e.stopPropagation(); }}
<ul className="overflow-y-auto hide-scrollbar pt-20 pb-10 flex flex-col justify-between h-full "> className={`fixed top-0 bottom-0 -right-full data-[isvisible=true]:right-0 transition-all duration-100 ease-in-out not-xl:!rounded-s-none overflow-clip bg-white w-[200px] h-dvh flex flex-col z-40`}>
<li> <ul className="overflow-hidden noscrollbar pt-20 pb-10 flex flex-col justify-between h-full ">
<div className='flex flex-col gap-[60px]'> <li>
<div className='flex flex-col gap-[23px]'> <div className='flex flex-col gap-[60px]'>
<div className='pt-2 text-start px-12 text-menu-header'>منو</div> <div className='flex flex-col gap-[23px]'>
{menuItems[0].map((v, i) => { <div className='pt-2 text-start px-12 text-menu-header'>منو</div>
return <MenuItem key={i} href={v.href || ''} title={v.title} icon={v.icon} /> {menuItems[0].map((v, i) => {
})} return <MenuItem key={i} href={v.href || ''} title={v.title} icon={v.icon} />
})}
</div>
</div> </div>
</div> </li>
</li> <li className='flex flex-col gap-6 pt-16'>
<li className='flex flex-col gap-6 pt-16'> {menuItems[2].map((v, i) => {
{menuItems[2].map((v, i) => { return <MenuItem key={i} href={v.href || ''} title={v.title} icon={v.icon} />
return <MenuItem key={i} href={v.href || ''} title={v.title} icon={v.icon} /> })}
})} <MenuItem href={''} title={''} icon={<></>}><NightModeSwitch checked={nightModeState} onClick={togglenightModeState} /></MenuItem>
<MenuItem href={''} title={''} icon={<></>}><NightModeSwitch checked={nightModeState} onClick={togglenightModeState}/></MenuItem> </li>
</li> </ul>
</ul> </div>
</div> </BlurredOverlayContainer>
</BlurredOverlayContainer> </div>
) )
} }
+1 -1
View File
@@ -11,7 +11,7 @@ type Props = object
function BottomNavBar({ }: Props) { function BottomNavBar({ }: Props) {
return ( return (
<div className="absolute bottom-0 left-0 w-full bg-transparent"> <div className="fixed bottom-0 left-0 w-full bg-transparent">
<div className="max-w-md mx-auto w-full aspect-[436/104] relative z-30"> <div className="max-w-md mx-auto w-full aspect-[436/104] relative z-30">
<svg <svg
viewBox="0 0 436 104" viewBox="0 0 436 104"
@@ -38,7 +38,8 @@ const BlurredOverlayContainer = ({
<button <button
data-visible={visible} data-visible={visible}
data-loaded={loaded} data-loaded={loaded}
className={`absolute inset-0 flex items-center justify-center className={props.className + `
absolute inset-0 flex items-center justify-center h-dvh w-full
backdrop-blur-sm backdrop-blur-sm
opacity-0 transition-opacity duration-300 ease-in-out delay-${effectdelay} opacity-0 transition-opacity duration-300 ease-in-out delay-${effectdelay}
${zClass} ${zClass}
+1 -1
View File
@@ -22,7 +22,7 @@ type Props = {
function TopBar({ profileDropState, toggleProfileDropState, menuState, toggleMenuState, searchModalState, toggleSearchModalState, nightModeState, toggleNightModeState }: Props) { function TopBar({ profileDropState, toggleProfileDropState, menuState, toggleMenuState, searchModalState, toggleSearchModalState, nightModeState, toggleNightModeState }: Props) {
return ( return (
<div className="p-4"> <div className="p-4 pb-0">
<div className='bg-white grid grid-cols-3 overflow-hidden rounded-2xl w-full h-12 lg:h-24 px-2 sm:p-6 xl:gap-8 items-center'> <div className='bg-white grid grid-cols-3 overflow-hidden rounded-2xl w-full h-12 lg:h-24 px-2 sm:p-6 xl:gap-8 items-center'>
<div className='inline-flex justify-between items-center'> <div className='inline-flex justify-between items-center'>
<button className='inline-block xl:hidden' onClick={toggleMenuState}> <button className='inline-block xl:hidden' onClick={toggleMenuState}>
@@ -36,7 +36,7 @@ function ClientMenuRouteWrapper({ children }: Props) {
// }, [location]) // }, [location])
return ( return (
<div> <div className='h-screen overflow-hidden'>
<header> <header>
<TopBar <TopBar
profileDropState={profileDrop} toggleProfileDropState={toggleProfileDrop} profileDropState={profileDrop} toggleProfileDropState={toggleProfileDrop}
@@ -44,7 +44,7 @@ function ClientMenuRouteWrapper({ children }: Props) {
searchModalState={searchModal} toggleSearchModalState={toggleSearchModal} searchModalState={searchModal} toggleSearchModalState={toggleSearchModal}
nightModeState={nightMode} toggleNightModeState={toggleNightMode} /> nightModeState={nightMode} toggleNightModeState={toggleNightMode} />
</header> </header>
<main className="h-full px-4 pb-0"> <main className="h-full px-4 pb-44 overflow-y-auto noscrollbar">
{children} {children}
</main> </main>
<aside> <aside>
+2 -2
View File
@@ -6,9 +6,9 @@ type Props = { } & React.AllHTMLAttributes<HTMLBodyElementEventMap>
function ClientSideWrapper({ children }: Props) { function ClientSideWrapper({ children }: Props) {
return ( return (
<div> <>
{children} {children}
</div> </>
) )
} }