improve: improved performance of menu list

This commit is contained in:
Mahyar Khanbolooki
2025-07-07 21:25:18 +03:30
parent a0f0405184
commit 7a69e5fd23
4 changed files with 220 additions and 241 deletions
+83 -186
View File
@@ -1,146 +1,120 @@
'use client'; 'use client';
import { Fragment, useCallback, useMemo, useState } from "react"; import { useCallback, useMemo, 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 EqualizerIcon from "@/components/icons/EqualizerIcon";
import TextAlignIcon from "@/components/icons/TextAlignIcon"; 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 VerticalScrollView from "@/components/listview/VerticalScrollView";
import MenuItemRenderer from "@/components/listview/MenuItemRenderer"; import MenuItemRenderer from "@/components/listview/MenuItemRenderer";
import { useReceiptStore } from "@/zustand/receiptStore"; import React from "react";
import MenuItem from "@/components/listview/MenuItem";
const categories = [ const categories = new Array(13).fill({ title: "خوراک", icon: "" });
{
title: 'خوراک', icon: '',
},
{
title: 'خوراک', icon: '',
},
{
title: 'خوراک', icon: '',
},
{
title: 'خوراک', icon: '',
},
{
title: 'خوراک', icon: '',
},
{
title: 'خوراک', icon: '',
},
{
title: 'خوراک', icon: '',
},
{
title: 'خوراک', icon: '',
},
{
title: 'خوراک', icon: '',
},
{
title: 'خوراک', icon: '',
},
{
title: 'خوراک', icon: '',
},
{
title: 'خوراک', icon: '',
},
{
title: 'خوراک', icon: '',
}
]
const foods = [ const foods = [
{ {
id: 1,
category: 0, category: 0,
price: '69.000', price: "69.000",
name: 'کباب چوبی ژیوان )سیخ چوبی(', name: "کباب چوبی ژیوان )سیخ چوبی(",
contains: 'فیله گوساله / سس سفید / قارچ و سیب زمینی/ پنیر پارمزان' contains: "فیله گوساله / سس سفید / قارچ و سیب زمینی/ پنیر پارمزان"
}, },
{ {
id: 2,
category: 1, category: 1,
price: '49.000', price: "49.000",
name: 'برگر گوشت گوساله', name: "برگر گوشت گوساله",
contains: 'گوشت گوساله / پنیر چدار / کاهو / گوجه' contains: "گوشت گوساله / پنیر چدار / کاهو / گوجه"
}, },
{ {
id: 3,
category: 2, category: 2,
price: '29.000', price: "29.000",
name: 'ساندویچ مرغ', name: "ساندویچ مرغ",
contains: 'مرغ / کاهو / گوجه / سس مایونز' contains: "مرغ / کاهو / گوجه / سس مایونز"
}, },
{ {
id: 4,
category: 0, category: 0,
price: '89.000', price: "89.000",
name: 'کباب ماهی', name: "کباب ماهی",
contains: 'ماهی / سس لیمو / قارچ و سیب زمینی' contains: "ماهی / سس لیمو / قارچ و سیب زمینی"
}, },
{ {
id: 5,
category: 1, category: 1,
price: '39.000', price: "39.000",
name: 'سالاد فصل', name: "سالاد فصل",
contains: 'کاهو / گوجه / خیار / پنیر فتا' contains: "کاهو / گوجه / خیار / پنیر فتا"
}, },
{ {
id: 6,
category: 3, category: 3,
price: '59.000', price: "59.000",
name: 'پاستا گوشت', name: "پاستا گوشت",
contains: 'گوشت گوساله / پاستا / سس گوجه' contains: "گوشت گوساله / پاستا / سس گوجه"
}, },
{ {
id: 7,
category: 2, category: 2,
price: '49.000', price: "49.000",
name: 'ساندویچ تن ماهی', name: "ساندویچ تن ماهی",
contains: 'تن ماهی / کاهو / گوجه / سس مایونز' contains: "تن ماهی / کاهو / گوجه / سس مایونز"
}, },
{ {
id: 8,
category: 0, category: 0,
price: '79.000', price: "79.000",
name: 'کباب گوشت گوسفندی', name: "کباب گوشت گوسفندی",
contains: 'گوشت گوسفندی / سس سفید / قارچ و سیب زمینی' contains: "گوشت گوسفندی / سس سفید / قارچ و سیب زمینی"
}, },
{ {
id: 9,
category: 1, category: 1,
price: '29.000', price: "29.000",
name: 'برگر مرغ', name: "برگر مرغ",
contains: 'مرغ / پنیر چدار / کاهو / گوجه' contains: "مرغ / پنیر چدار / کاهو / گوجه"
}, },
{ {
id: 10,
category: 3, category: 3,
price: '69.000', price: "69.000",
name: 'سالاد میگو', name: "سالاد میگو",
contains: 'میگو / کاهو / گوجه / خیار' contains: "میگو / کاهو / گوجه / خیار"
}, },
{ {
id: 11,
category: 2, category: 2,
price: '39.000', price: "39.000",
name: 'ساندویچ شاورما', name: "ساندویچ شاورما",
contains: 'مرغ / کاهو / گوجه / سس تارتار' contains: "مرغ / کاهو / گوجه / سس تارتار"
}, },
{ {
id: 12,
category: 0, category: 0,
price: '99.000', price: "99.000",
name: 'کباب گوشت بوقلمون', name: "کباب گوشت بوقلمون",
contains: 'گوشت بوقلمون / سس سفید / قارچ و سیب زمینی' contains: "گوشت بوقلمون / سس سفید / قارچ و سیب زمینی"
}, },
{ {
id: 13,
category: 1, category: 1,
price: '59.000', price: "59.000",
name: 'برگر ماهی', name: "برگر ماهی",
contains: 'ماهی / پنیر چدار / کاهو / گوجه' contains: "ماهی / پنیر چدار / کاهو / گوجه"
}, },
{ {
id: 14,
category: 3, category: 3,
price: '49.000', price: "49.000",
name: 'سالاد مرغ', name: "سالاد مرغ",
contains: 'مرغ / کاهو / گوجه / خیار' contains: "مرغ / کاهو / گوجه / خیار"
}, }
]; ];
const MenuIndex = () => { const MenuIndex = () => {
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [selectedCategory, setSelectedCategory] = useState(0); const [selectedCategory, setSelectedCategory] = useState(0);
@@ -153,51 +127,41 @@ const MenuIndex = () => {
setSelectedCategory(id); setSelectedCategory(id);
}, []); }, []);
const incrementItem = useReceiptStore((state) => state.increment);
const decrementItem = useReceiptStore((state) => state.decrement);
const receiptItems = useReceiptStore((state) => state.items);
const filteredReceiptItems = useMemo(() => { const filteredReceiptItems = useMemo(() => {
return foods.filter((item) => item.category === selectedCategory); const lowerSearch = search.toLowerCase();
}, [selectedCategory]); return foods.filter(
(item) =>
item.category === selectedCategory &&
item.name.toLowerCase().includes(lowerSearch)
);
}, [selectedCategory, search]);
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 <HorizontalScrollView className="w-full noscrollbar py-4!">
className="w-full noscrollbar py-4!" {categories.map((item, index) => (
itemRenderer={(child, index) => {
return (
<CategoryItemRenderer <CategoryItemRenderer
className={`${index === selectedCategory && "bg-white!"}`}
onClick={() => updateCategory(index)}
key={index} key={index}
className={`${index === selectedCategory ? "bg-white!" : ""}`}
onClick={() => updateCategory(index)}
> >
{child}
</CategoryItemRenderer>
);
}}
>
{categories.map((item, index) => {
return (
<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> </CategoryItemRenderer>
); ))}
})}
</HorizontalScrollView> </HorizontalScrollView>
<section className="w-full"> <section className="w-full">
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<span className="text-base font-semibold"> <span className="text-base font-semibold">
{categories[selectedCategory].title} {categories[selectedCategory]?.title}
</span> </span>
<div className="inline-flex gap-2 justify-around items-center"> <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]"> <button className="rounded-xl h-8 bg-white ps-4 pe-2 py-1.5 inline-flex items-center justify-between gap-[7px]">
@@ -210,79 +174,12 @@ const MenuIndex = () => {
</div> </div>
</div> </div>
<VerticalScrollView <VerticalScrollView className="mt-5! overflow-y-auto h-full">
className="mt-5! overflow-y-auto h-full" {filteredReceiptItems.map((food) => (
itemRenderer={(child, index) => { <MenuItemRenderer key={food.id}>
return ( <MenuItem food={food} />
<MenuItemRenderer key={index}>{child}</MenuItemRenderer> </MenuItemRenderer>
); ))}
}}
>
{filteredReceiptItems
.filter((food) => food.category === selectedCategory)
.map((food, index) => {
const receiptItem = receiptItems.find(
(item) => item.id === 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">
{(receiptItem?.quantity || 0) <= 0 ? (
<button
onClick={() => incrementItem(index)}
className="inline-flex w-full justify-center items-center gap-2"
>
<PlusIcon />
<div className="text-sm2 pt-0.5 font-semibold" style={{ verticalAlign: "end" }}>
افزودن
</div>
</button>
) : (
<>
<button
onClick={() => incrementItem(index)}
className="bg-white rounded-sm p-2"
>
<PlusIcon />
</button>
<div className="text-sm2 pt-0.5 font-semibold" style={{ verticalAlign: "end" }}>
{(receiptItem?.quantity || 0)}
</div>
<button
onClick={() => decrementItem(index)}
className="bg-white rounded-sm p-2"
>
<MinusIcon />
</button>
</>
)}
</div>
</div>
</div>
</Fragment>
);
})}
</VerticalScrollView> </VerticalScrollView>
</section> </section>
</div> </div>
+76
View File
@@ -0,0 +1,76 @@
// components/menu/MenuItem.tsx
'use client'
import { memo } from "react";
import Image from "next/image";
import PlusIcon from "@/components/icons/PlusIcon";
import MinusIcon from "@/components/icons/MinusIcon";
import { useReceiptStore } from "@/zustand/receiptStore";
interface MenuItemProps {
food: {
id: number
name: string;
contains: string;
price: string;
};
}
const MenuItem = ({ food }: MenuItemProps) => {
const quantity = useReceiptStore(state => state.items[food.id]?.quantity || 0);
const increment = useReceiptStore(state => state.increment);
const decrement = useReceiptStore(state => state.decrement);
return (
<div className="flex gap-4 w-full">
<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">
{quantity <= 0 ? (
<button
onClick={() => increment(food.id)}
className="inline-flex w-full justify-center items-center gap-2"
>
<PlusIcon />
<div className="text-sm2 pt-0.5 font-semibold">افزودن</div>
</button>
) : (
<>
<button
onClick={() => increment(food.id)}
className="bg-white rounded-sm p-2"
>
<PlusIcon />
</button>
<div className="text-sm2 pt-0.5 font-semibold">{quantity}</div>
<button
onClick={() => decrement(food.id)}
className="bg-white rounded-sm p-2"
>
<MinusIcon />
</button>
</>
)}
</div>
</div>
</div>
</div>
);
};
export default memo(MenuItem);
+10 -2
View File
@@ -4,12 +4,20 @@ type Props = {
children: React.ReactNode; children: React.ReactNode;
} & React.HTMLAttributes<HTMLDivElement>; } & React.HTMLAttributes<HTMLDivElement>;
function MenuItemRenderer({ children, ...rest }: Props) { function MenuItemRendererComponent({ children, ...rest }: Props) {
return ( 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`}> <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} {children}
</div> </div>
); );
} }
// Memoize with shallow comparison of props
const MenuItemRenderer = React.memo(MenuItemRendererComponent, (prevProps, nextProps) => {
return prevProps.className === nextProps.className && prevProps.children === nextProps.children;
});
export default MenuItemRenderer; export default MenuItemRenderer;
+36 -38
View File
@@ -1,52 +1,50 @@
// zustand/userStore.ts // zustand/receiptStore.ts
import { create } from 'zustand' import { create } from 'zustand';
import { createJSONStorage, persist } from 'zustand/middleware' import { createJSONStorage, persist } from 'zustand/middleware';
type ReceiptItem = { type ReceiptItemMap = {
id: number [id: number]: {
quantity: number quantity: number;
} };
};
type AuthStore = { type ReceiptStore = {
items: Array<ReceiptItem> items: ReceiptItemMap;
increment: (id: number) => void increment: (id: number) => void;
decrement: (id: number) => void decrement: (id: number) => void;
clear: () => void clear: () => void;
} };
export const useReceiptStore = create<AuthStore>()( export const useReceiptStore = create<ReceiptStore>()(
persist( persist(
(set) => ({ (set) => ({
items: [], items: {},
clear: () => clear: () => set({ items: {} }),
set(() => ({
items: []
})),
increment: (id) => increment: (id) =>
set((state) => { set((state) => {
if (state.items.find(x => x.id === id) === undefined) { const current = state.items[id]?.quantity || 0;
return ({ return {
items: [...state.items, {id, quantity: 1}] items: {
}) ...state.items,
} [id]: { quantity: current + 1 },
return ({ },
items: state.items.map((item) => };
item.id === id ? { ...item, quantity: item.quantity + 1 } : item
),
})
}), }),
decrement: (id) => decrement: (id) =>
set((state) => ({ set((state) => {
items: state.items.map((item) => const current = state.items[id]?.quantity || 0;
item.id === id && item.quantity > 0 if (current <= 0) return {};
? { ...item, quantity: item.quantity - 1 } return {
: item items: {
), ...state.items,
})), [id]: { quantity: current - 1 },
},
};
}),
}), }),
{ {
name: 'receipt-storage', // Key in localStorage name: 'receipt-storage',
storage: createJSONStorage(() => sessionStorage), storage: createJSONStorage(() => sessionStorage),
} }
) )
) );