From a707163ac92cf9c9fe08e632b6aba03b9b560ace Mon Sep 17 00:00:00 2001 From: Simune <111689342+chaeyun-sim@users.noreply.github.com> Date: Mon, 26 Jan 2026 16:43:24 +0900 Subject: [PATCH 01/46] =?UTF-8?q?feat:=20=EB=A9=94=EB=89=B4,=20=EC=B9=B4?= =?UTF-8?q?=ED=85=8C=EA=B3=A0=EB=A6=AC=20=EA=B4=80=EB=A0=A8=20api=20?= =?UTF-8?q?=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/categories/keys.ts | 6 ++++ src/api/categories/mutations.ts | 31 +++++++++++++++++ src/api/categories/queries.ts | 16 +++++++++ src/api/menus/keys.ts | 6 ++++ src/api/menus/mutations.ts | 60 +++++++++++++++++++++++++++++++++ src/api/menus/queries.ts | 25 ++++++++++++++ src/types/api.ts | 7 ++++ 7 files changed, 151 insertions(+) create mode 100644 src/api/categories/keys.ts create mode 100644 src/api/categories/mutations.ts create mode 100644 src/api/categories/queries.ts create mode 100644 src/api/menus/keys.ts create mode 100644 src/api/menus/mutations.ts create mode 100644 src/api/menus/queries.ts create mode 100644 src/types/api.ts diff --git a/src/api/categories/keys.ts b/src/api/categories/keys.ts new file mode 100644 index 0000000..f6ad9c4 --- /dev/null +++ b/src/api/categories/keys.ts @@ -0,0 +1,6 @@ +const CATEGORY = "CATEGORY"; + +export const CATEGORY_KEY = { + category: () => [CATEGORY], + categories: () => [CATEGORY, "list"], +}; diff --git a/src/api/categories/mutations.ts b/src/api/categories/mutations.ts new file mode 100644 index 0000000..02c327d --- /dev/null +++ b/src/api/categories/mutations.ts @@ -0,0 +1,31 @@ +import { mutationOptions } from '@tanstack/react-query'; +import { instance } from '@/api'; +import type { MoveRequest } from '@/types/api'; +import type { Category } from '@/types/domain/menu'; + +export const categoryMutations = { + createCategory: () => mutationOptions({ + mutationFn: async ({ storeId, data }: { storeId: string; data: Pick }) => { + const response = await instance.post(`/stores/${storeId}/categories`, data); + return response.data; + }, + }), + updateCategory: () => mutationOptions({ + mutationFn: async ({storeId, categoryId, data}: {storeId: string; categoryId: string; data: Pick }) => { + const response = await instance.put(`/stores/${storeId}/categories/${categoryId}`, data); + return response.data; + }, + }), + deleteCategory: () => mutationOptions({ + mutationFn: async ({storeId, categoryId}: {storeId: string; categoryId: string}) => { + const response = await instance.delete(`/stores/${storeId}/categories/${categoryId}`); + return response.data; + }, + }), + moveCategories: () => mutationOptions({ + mutationFn: async ({ storeId, sourceId, targetId, where }: { storeId: string; } & MoveRequest) => { + const response = await instance.post(`/stores/${storeId}/categories/${sourceId}/move/${targetId}`, { where }); + return response.data; + }, + }), +}; diff --git a/src/api/categories/queries.ts b/src/api/categories/queries.ts new file mode 100644 index 0000000..e9d881e --- /dev/null +++ b/src/api/categories/queries.ts @@ -0,0 +1,16 @@ +import { queryOptions } from '@tanstack/react-query'; +import { instance } from '@/api'; +import { CATEGORY_KEY } from '@/api/categories/keys'; +import type { Category } from '@/types/domain/menu'; + +export const categoryQueries = { + getCategories: ({ storeId }: { storeId: string }) => + queryOptions<{ categories: Category[] }>({ + queryKey: CATEGORY_KEY.category(), + queryFn: async () => { + const response = await instance.get(`/stores/${storeId}/categories`); + return response.data; + }, + enabled: !!storeId, + }), +}; diff --git a/src/api/menus/keys.ts b/src/api/menus/keys.ts new file mode 100644 index 0000000..cd3667f --- /dev/null +++ b/src/api/menus/keys.ts @@ -0,0 +1,6 @@ +const MENU = "MENU"; + +export const MENUS_KEY = { + menu: () => [MENU], + menuDetail: (menuId: string) => [MENU, menuId], +}; diff --git a/src/api/menus/mutations.ts b/src/api/menus/mutations.ts new file mode 100644 index 0000000..02e6547 --- /dev/null +++ b/src/api/menus/mutations.ts @@ -0,0 +1,60 @@ +import { mutationOptions } from '@tanstack/react-query'; +import { instance } from '@/api'; +import type { MenuSchema } from '@/schema/menu.schema'; +import type { MoveRequest, PropsWithStoreId } from '@/types/api'; +import type { MenuOptionGroup } from '@/types/domain/menu'; + +export const menuMutations = { + createMenu: () => mutationOptions({ + mutationFn: async ({ storeId, categoryId, data }: PropsWithStoreId<{ categoryId: string; data: {file: File, request: Omit} }>) => { + const formData = new FormData(); + formData.append("file", data.file); + formData.append("request", JSON.stringify(data.request)); + + const response = await instance.post(`/stores/${storeId}/categories/${categoryId}/menus`, formData, { + headers: { + "Content-Type": "multipart/form-data", + }, + }); + return response.data; + }, + }), + updateMenu: () => mutationOptions({ + mutationFn: async ({ storeId, menuId, data }: PropsWithStoreId<{ menuId: string; data: Omit & {menuOptionGroups: Omit[]} }>) => { + const response = await instance.put(`/stores/${storeId}/menus/${menuId}`, data); + return response.data; + }, + }), + updateMenuWithImage: () => mutationOptions({ + mutationFn: async ({ storeId, menuId, data }: PropsWithStoreId<{ menuId: string; data: {file: File, request: Omit & {menuOptionGroups: Omit[]}} }>) => { + const formData = new FormData(); + formData.append("file", data.file); + formData.append("request", JSON.stringify(data.request)); + + const response = await instance.put(`/stores/${storeId}/menus/${menuId}/with-image`, data, { + headers: { + "Content-Type": "multipart/form-data", + }, + }); + return response.data; + }, + }), + deleteMenu: () => mutationOptions({ + mutationFn: async ({ storeId, menuId, categoryId }: PropsWithStoreId<{ menuId: string; categoryId: string }>) => { + const response = await instance.delete(`/stores/${storeId}/categories/${categoryId}/menus/${menuId}`); + return response.data; + }, + }), + multiDeleteMenus: () => mutationOptions({ + mutationFn: async ({ storeId, data }: PropsWithStoreId<{ data: { menuIds: string[]} }>) => { + const response = await instance.delete(`/stores/${storeId}/menus/delete`, { data }); + return response.data; + }, + }), + moveMenus: () => mutationOptions({ + mutationFn: async ({ storeId, sourceId, targetId, where }: PropsWithStoreId) => { + const response = await instance.post(`/stores/${storeId}/menus/${sourceId}/move/${targetId}`, { where }); + return response.data; + }, + }), +}; diff --git a/src/api/menus/queries.ts b/src/api/menus/queries.ts new file mode 100644 index 0000000..444fd5c --- /dev/null +++ b/src/api/menus/queries.ts @@ -0,0 +1,25 @@ +import { queryOptions } from '@tanstack/react-query'; +import { instance } from '@/api'; +import { MENUS_KEY } from '@/api/menus/keys'; +import type { Menu, MenuDetail } from '@/types/domain/menu'; + +export const menuQueries = { + getMenus: ({ storeId, categoryId }: { storeId: string; categoryId: string }) => + queryOptions<{ menus: Menu[] }>({ + queryKey: MENUS_KEY.menu(), + queryFn: async () => { + const response = await instance.get(`/stores/${storeId}/categories/${categoryId}/menus`); + return response.data; + }, + enabled: !!storeId && !!categoryId, + }), + getMenuDetail: ({ storeId, menuId, categoryId }: { storeId: string; menuId: string; categoryId: string }) => + queryOptions({ + queryKey: MENUS_KEY.menuDetail(menuId), + queryFn: async () => { + const response = await instance.get(`/stores/${storeId}/categories/${categoryId}/menus/${menuId}`); + return response.data; + }, + enabled: !!storeId && !!menuId && !!categoryId, + }), +}; diff --git a/src/types/api.ts b/src/types/api.ts new file mode 100644 index 0000000..b3defb5 --- /dev/null +++ b/src/types/api.ts @@ -0,0 +1,7 @@ +export interface MoveRequest { + sourceId: string; + targetId: string; + where: 'PREV' | 'NEXT'; +} + +export type PropsWithStoreId = T & { storeId: string }; From ba4f5fc9b61c0b74c5d9cc112cc8ed250aa02dfa Mon Sep 17 00:00:00 2001 From: Simune <111689342+chaeyun-sim@users.noreply.github.com> Date: Mon, 26 Jan 2026 18:23:57 +0900 Subject: [PATCH 02/46] =?UTF-8?q?feat:=20=EB=A9=94=EB=89=B4=20=EA=B4=80?= =?UTF-8?q?=EB=A0=A8=20=ED=9B=85=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/hooks/menu/useCheckMenu.ts | 22 +++++++++++++ src/hooks/menu/useMenu.ts | 22 +++++++++++++ src/hooks/menu/useMenuMove.ts | 56 ++++++++++++++++++++++++++++++++++ 3 files changed, 100 insertions(+) create mode 100644 src/hooks/menu/useCheckMenu.ts create mode 100644 src/hooks/menu/useMenu.ts create mode 100644 src/hooks/menu/useMenuMove.ts diff --git a/src/hooks/menu/useCheckMenu.ts b/src/hooks/menu/useCheckMenu.ts new file mode 100644 index 0000000..560ed2d --- /dev/null +++ b/src/hooks/menu/useCheckMenu.ts @@ -0,0 +1,22 @@ +import { useState } from 'react'; +import type { Menu } from '@/types/domain/menu'; + +function useCheckMenu() { + const [checkedMenus, setCheckedMenus] = useState([]); + + const toggleCheckMenu = (menu: Menu) => { + if (checkedMenus.includes(menu)) { + setCheckedMenus(checkedMenus.filter((m) => m.menuId !== menu.menuId)); + } else { + setCheckedMenus([...checkedMenus, menu]); + } + } + + return { + checkedMenus, + toggleCheckMenu, + resetCheckedMenus: () => setCheckedMenus([]), + } +} + +export default useCheckMenu; \ No newline at end of file diff --git a/src/hooks/menu/useMenu.ts b/src/hooks/menu/useMenu.ts new file mode 100644 index 0000000..ae60f1d --- /dev/null +++ b/src/hooks/menu/useMenu.ts @@ -0,0 +1,22 @@ +import { useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { categoryQueries } from '@/api/categories/queries'; +import { menuQueries } from '@/api/menus/queries'; + +function useMenu() { + const [selectedCategory, setSelectedCategory] = useState('all'); + + const storeId = localStorage.getItem('storeId') as string; + const getCategories = useQuery(categoryQueries.getCategories({ storeId })) + const getMenus = useQuery(menuQueries.getMenus({ storeId, categoryId: selectedCategory ?? "" })) + + return { + storeId, + selectedCategory, + setSelectedCategory, + getCategories, + getMenus, + } +} + +export default useMenu; \ No newline at end of file diff --git a/src/hooks/menu/useMenuMove.ts b/src/hooks/menu/useMenuMove.ts new file mode 100644 index 0000000..39433d5 --- /dev/null +++ b/src/hooks/menu/useMenuMove.ts @@ -0,0 +1,56 @@ +import { useState } from 'react'; +import { useMutation } from '@tanstack/react-query'; +import { toast } from 'sonner'; +import { menuMutations } from '@/api/menus/mutations'; +import { errorResponse } from '@/lib/error-response'; +import type { MoveRequest } from '@/types/api'; + +function useMenuMove() { + const { mutateAsync: moveMenus } = useMutation(menuMutations.moveMenus()) + + const [isSubmittingOrderChange, setIsSubmittingOrderChange] = useState(false); + const [isChangedToMenuOrder, setIsChangedToMenuOrder] = useState(false); + const [changeOrdersList, setChangeOrdersList] = useState([]); + + /** + * 메뉴 순서 변경 초기화 기능 + */ + const handleResetMoves = () => { + setChangeOrdersList([]); + setIsChangedToMenuOrder(false); + }; + + /** + * 메뉴 순서 변경 기능 + */ + const handleSaveMoves = async () => { + const storeId = localStorage.getItem('storeId') as string; + + try { + setIsSubmittingOrderChange(true); + + for (const { sourceId, targetId, where } of changeOrdersList) { + await moveMenus({ storeId, sourceId, targetId, where }); + } + + toast.success("메뉴 순서 변경이 완료되었습니다."); + handleResetMoves(); + } catch (error) { + toast.error(errorResponse(error).data.message); + } finally { + setIsSubmittingOrderChange(false); + } + }; + + return { + isSubmittingOrderChange, + isChangedToMenuOrder, + changeOrdersList, + saveMoves: handleSaveMoves, + resetMoves: handleResetMoves, + changeToMenuOrder: () => setIsChangedToMenuOrder(true), + addToChangeList: (props: MoveRequest) => setChangeOrdersList((prev) => [...prev, props]), + } +} + +export default useMenuMove; \ No newline at end of file From af6e4267fa13460dc04bdd75ff0ad64724975d6a Mon Sep 17 00:00:00 2001 From: Simune <111689342+chaeyun-sim@users.noreply.github.com> Date: Mon, 26 Jan 2026 18:24:26 +0900 Subject: [PATCH 03/46] =?UTF-8?q?feat:=20=EB=A9=94=EB=89=B4=20=EB=B0=8F=20?= =?UTF-8?q?=EC=B9=B4=ED=85=8C=EA=B3=A0=EB=A6=AC=20api=20=EC=97=B0=EA=B2=B0?= =?UTF-8?q?=20=EB=B0=8F=20=ED=9B=85=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/main/owner/menus/CategoryModal.tsx | 110 ++++++----- src/pages/main/owner/menus/MainMenuPage.tsx | 186 ++++++------------ .../menus/category/MainMenuCategoryPage.tsx | 49 ++++- 3 files changed, 166 insertions(+), 179 deletions(-) diff --git a/src/pages/main/owner/menus/CategoryModal.tsx b/src/pages/main/owner/menus/CategoryModal.tsx index 8bf556c..9f53f7c 100644 --- a/src/pages/main/owner/menus/CategoryModal.tsx +++ b/src/pages/main/owner/menus/CategoryModal.tsx @@ -1,67 +1,91 @@ import { useEffect, useState } from "react"; +import { useMutation } from '@tanstack/react-query'; import { useFieldArray, useForm } from "react-hook-form"; +import { toast } from 'sonner'; +import { CATEGORY_KEY } from '@/api/categories/keys'; +import { categoryMutations } from '@/api/categories/mutations'; import { Form } from "@/components/form/Form"; import FormField from "@/components/form/FormField"; import { DragDrop, Plus, Trash, UpsideDown } from "@/components/icons"; import Modal from "@/components/overlay/Modal"; import Button from "@/components/ui/Button/Button"; import { DragList } from "@/components/ui/Drag/DragList"; +import { errorResponse } from '@/lib/error-response'; +import { queryClient } from '@/lib/query-client'; import cn from "@/lib/utils"; +import type { MoveRequest } from '@/types/api'; import type { Category } from "@/types/domain/menu"; import type { ModalProps } from "@/types/overlay"; interface CategoryModalProps extends ModalProps { categories: Category[]; - onSave?: (categories: Category[]) => void; } -function CategoryModal({ isOpen, close, categories, onSave }: Readonly) { - const form = useForm({ - defaultValues: { - categories, - }, - }); +function CategoryModal({ isOpen, close, categories }: Readonly) { + const storeId = localStorage.getItem('storeId') as string; + + const form = useForm({ defaultValues: { categories }}); const { fields, append, remove } = useFieldArray({ control: form.control, name: "categories", }); + const { mutateAsync: moveCategories } = useMutation(categoryMutations.moveCategories()) + const { mutateAsync: createCategory } = useMutation(categoryMutations.createCategory()) + useEffect(() => { form.reset({ categories }); - }, [categories, form]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [categories]); const [mode, setMode] = useState<"CREATE" | "CHANGE_ORDER">("CREATE"); - const isCreating = mode === "CREATE"; - const isChangingOrder = mode === "CHANGE_ORDER"; - - const [changeOrdersList, setChangeOrdersList] = useState< - { sourceId: string; targetId: string; where: "PREV" | "NEXT" }[] - >([]); - - const handleAddCategory = () => append({ categoryId: String(fields.length + 1), name: "" }); - const handleDeleteCategory = (index: number) => remove(index); - const handleSave = () => { - // TODO: 카테고리 저장 API 호출 + const [changeOrdersList, setChangeOrdersList] = useState([]); - onSave?.(form.getValues().categories); + /** + * 카테고리 저장 기능 + */ + const handleSave = async () => { + try { + const formCategories = form.getValues("categories"); + const newCategories = formCategories.filter( + (fc) => !categories.some((c) => c.categoryId === fc.categoryId) + ); + + for (const category of newCategories) { + await createCategory({ storeId, data: { name: category.name } }); + } + + toast.success("카테고리 저장이 완료되었습니다."); + queryClient.invalidateQueries({ queryKey: CATEGORY_KEY.category() }); close(); - }; + } catch (error) { + toast.error(errorResponse(error).data.message); + } +}; + /** + * 카테고리 순서 저장 기능 + */ const handleSaveChanges = async () => { - changeOrdersList.forEach(() => { - // TODO: 카테고리 순서 변경 API 호출 (sourceId, targetId, where) - }); + try { + for (const { sourceId, targetId, where } of changeOrdersList) { + await moveCategories({ storeId, sourceId, targetId, where }); + } - onSave?.(form.getValues().categories); - setMode("CREATE"); - setChangeOrdersList([]); + setMode("CREATE"); + setChangeOrdersList([]); + queryClient.invalidateQueries({ queryKey: CATEGORY_KEY.category() }); + } catch (error) { + toast.error(errorResponse(error).data.message); + } }; + /** + * 카테고리 순서 초기화 기능 + */ const handleResetChanges = () => { - // TODO: 카테고리 순서 변경 초기화 로직 구현 - form.reset({ categories }); setMode("CREATE"); setChangeOrdersList([]); @@ -76,16 +100,16 @@ function CategoryModal({ isOpen, close, categories, onSave }: Readonly - {isCreating ? "저장하기" : "순서 저장하기"} + {mode === "CREATE" ? "저장하기" : "순서 저장하기"} ), cancel: ( @@ -97,14 +121,14 @@ function CategoryModal({ isOpen, close, categories, onSave }: Readonly - {isCreating ? "닫기" : "취소"} + {mode === "CREATE" ? "닫기" : "취소"} ), }} topRightContent={ - isCreating ? ( + mode === "CREATE" ? ( diff --git a/src/pages/main/owner/menus/MainMenuPage.tsx b/src/pages/main/owner/menus/MainMenuPage.tsx index 3eb36be..d89b66b 100644 --- a/src/pages/main/owner/menus/MainMenuPage.tsx +++ b/src/pages/main/owner/menus/MainMenuPage.tsx @@ -1,122 +1,79 @@ -import { useState } from "react"; import { rectSortingStrategy } from "@dnd-kit/sortable"; -import Lottie from "lottie-react"; import { overlay } from "overlay-kit"; import useMediaQuery from "react-responsive"; import { useNavigate } from "react-router-dom"; -import successApplication from "@/assets/json/success-application.json"; import { Plus, Settings, Trash, UpsideDown } from "@/components/icons"; import MobileTitle from "@/components/layout/MobileTitle"; import Button from "@/components/ui/Button/Button"; import { DragList } from "@/components/ui/Drag/DragList"; +import useCheckMenu from '@/hooks/menu/useCheckMenu'; +import useMenu from '@/hooks/menu/useMenu'; +import useMenuMove from '@/hooks/menu/useMenuMove'; +import CategoryEmptyState from '@/pages/main/owner/menus/CategoryEmptyState'; import CategoryModal from "@/pages/main/owner/menus/CategoryModal"; import MenuCard from "@/pages/main/owner/menus/MenuCard"; import MenuDeleteAlert from "@/pages/main/owner/menus/MenuDeleteAlert"; import MenuDetailModal from "@/pages/main/owner/menus/MenuDetailModal"; -import { CATEGORIES_MOCK, MENUS_MOCK } from "@/pages/main/owner/menus/mock"; -import type { Menu } from "@/types/domain/menu"; function MainMenuPage() { const navigate = useNavigate(); const isMobile = useMediaQuery({ maxWidth: 959 }); - const [selectedCategory, setSelectedCategory] = useState("all"); - const [checkedMenus, setCheckedMenus] = useState([]); - const [categories, setCategories] = useState(CATEGORIES_MOCK); - - const filteredMenus = - selectedCategory === "all" - ? MENUS_MOCK - : MENUS_MOCK.filter((menu) => menu.categoryId === selectedCategory); - - const [menus, setMenus] = useState(filteredMenus); - const [originalMenus, setOriginalMenus] = useState(filteredMenus); - - const hasCategory = categories.length > 0; - - const [isSaving, setIsSaving] = useState(false); - const [isChangedMenuOrder, setIsChangedMenuOrder] = useState(false); - const [changeOrdersList, setChangeOrdersList] = useState< - { - sourceId: string; - targetId: string; - where: "PREV" | "NEXT"; - }[] - >([]); - - const handleCategoryChange = (categoryId: string) => { - const newFilteredMenus = - categoryId === "all" - ? MENUS_MOCK - : MENUS_MOCK.filter((menu) => menu.categoryId === categoryId); - - setSelectedCategory(categoryId); - setMenus(newFilteredMenus); - setOriginalMenus(newFilteredMenus); - setCheckedMenus([]); - }; + const { selectedCategory, setSelectedCategory, getCategories, getMenus } = useMenu() + const { isChangedToMenuOrder, isSubmittingOrderChange, changeToMenuOrder, saveMoves, resetMoves, addToChangeList } = useMenuMove() + const { checkedMenus, toggleCheckMenu, resetCheckedMenus } = useCheckMenu() + /** + * 카테고리 모달 열기 기능 + */ const handleOpenCategoryModal = () => { - overlay.open((overlayProps) => ( - - )); - }; - - const handleOpenMenuDetailModal = (menuId: string) => { - overlay.open((overlayProps) => ( - - )); + overlay.open((overlayProps) => ); }; + /** + * 메뉴 추가 모달 열기 기능 + */ const handleOpenCreateMenuModal = () => { - overlay.open((overlayProps) => ( - - )); - }; - - const handleOpenMenuDeleteAlert = () => { - overlay.open((overlayProps) => ( - - )); + overlay.open((overlayProps) => ); }; - const handleSaveChanges = async () => { - setIsSaving(true); - - changeOrdersList.forEach(() => { - // TODO: 메뉴 순서 변경 로직 구현 - }); - - setOriginalMenus(menus); - setChangeOrdersList([]); - setIsChangedMenuOrder(false); - setIsSaving(false); + /** + * 메뉴 상세 모달 열기 기능 + */ + const handleOpenMenuDetailModal = (menuId: string) => { + overlay.open((overlayProps) => ); }; - const handleResetChanges = () => { - setMenus(originalMenus); - setChangeOrdersList([]); - setIsChangedMenuOrder(false); + /** + * 메뉴 삭제 알림 모달 열기 기능 + */ + const handleOpenMenuDeleteAlert = () => { + overlay.open((overlayProps) => ); }; - const handleStartChangeOrder = () => { - setOriginalMenus(menus); - setIsChangedMenuOrder(true); + /** + * 현재 카테고리 변경 기능 + */ + const handleCategoryChange = (categoryId: string) => { + setSelectedCategory(categoryId); + resetCheckedMenus() }; const categoryLgClassName = (isSelected: boolean) => { if (isSelected) return "h-10!"; - if (isChangedMenuOrder) return "h-10! text-[15px] font-normal text-gray-300"; + if (isChangedToMenuOrder) return "h-10! text-[15px] font-normal text-gray-300"; return "h-10! border-gray-300 text-[15px] font-normal text-gray-300"; }; const categoryMdSmClassName = (isSelected: boolean) => { if (isSelected) return ""; - if (isChangedMenuOrder) return "text-s font-normal text-gray-300"; + if (isChangedToMenuOrder) return "text-s font-normal text-gray-300"; return "border-gray-300 text-s font-normal text-gray-300"; }; - return hasCategory ? ( + if (getCategories.isLoading) return null; + + return getCategories.data && getCategories.data.length > 0 ? (
메뉴 관리
@@ -139,18 +96,18 @@ function MainMenuPage() { className: "rounded-xl shrink-0 size-9 bg-gray-700 items-center justify-center p-0", }, }} - onClick={() => !isChangedMenuOrder && handleOpenCategoryModal()} - disabled={isChangedMenuOrder} + onClick={() => !isChangedToMenuOrder && handleOpenCategoryModal()} + disabled={isChangedToMenuOrder} > - {[{ categoryId: "all", name: "전체" }, ...categories].map((category) => { + {[{ categoryId: "all", name: "전체" }, ...getCategories.data ?? []].map((category) => { const isSelected = selectedCategory === category.categoryId; return ( ); })}
- {isChangedMenuOrder ? ( + {isChangedToMenuOrder ? (
메뉴의 순서 변경은 메뉴를 꾹 누르신 후, 원하시는 자리로 메뉴를 이동해주세요 @@ -182,12 +139,12 @@ function MainMenuPage() { -
@@ -195,7 +152,7 @@ function MainMenuPage() {
- {!isChangedMenuOrder && ( + {!isChangedToMenuOrder && ( )} { - setMenus(items); - setChangeOrdersList((prev) => [ - ...prev, - { sourceId: String(sourceId), targetId: String(targetId), where }, - ]); + items={getMenus.data ?? []} + onReorder={(_, sourceId, targetId, where) => { + addToChangeList({ sourceId: String(sourceId), targetId: String(targetId), where }); }} - canDrag={isChangedMenuOrder} + canDrag={isChangedToMenuOrder} keyExtractor={(item) => item.menuId} strategy={rectSortingStrategy} renderItem={(menu) => ( { - if (checkedMenus.includes(menu)) { - setCheckedMenus(checkedMenus.filter((m) => m.menuId !== menu.menuId)); - } else { - setCheckedMenus([...checkedMenus, menu]); - } - }} + onCheckedChange={() => toggleCheckMenu(menu)} onClick={() => { if (isMobile === true || isMobile === undefined) { navigate(`/menus/${menu.menuId}`, { @@ -260,35 +207,14 @@ function MainMenuPage() { handleOpenMenuDetailModal(menu.menuId); } }} - disabled={isChangedMenuOrder} + disabled={isChangedToMenuOrder} /> )} />
) : ( -
- -
- -

{`음식의 카테고리가 등록되어있지 않아요.\n아래 버튼을 눌러 카테고리를 등록해주세요.`}

- -
-
+ ); } diff --git a/src/pages/main/owner/menus/category/MainMenuCategoryPage.tsx b/src/pages/main/owner/menus/category/MainMenuCategoryPage.tsx index fad8944..9e712f9 100644 --- a/src/pages/main/owner/menus/category/MainMenuCategoryPage.tsx +++ b/src/pages/main/owner/menus/category/MainMenuCategoryPage.tsx @@ -1,14 +1,28 @@ +import { useEffect, useState } from 'react'; +import { useMutation, useQuery } from '@tanstack/react-query'; import { useForm, useWatch } from "react-hook-form"; import { useNavigate } from "react-router-dom"; +import { toast } from 'sonner'; +import { CATEGORY_KEY } from '@/api/categories/keys'; +import { categoryMutations } from '@/api/categories/mutations'; +import { categoryQueries } from '@/api/categories/queries'; +import Spinner from '@/components/feedback/Spinner'; import { Form } from "@/components/form/Form"; import FormField from "@/components/form/FormField"; import { Plus } from "@/components/icons"; import MobileTitle from "@/components/layout/MobileTitle"; import Button from "@/components/ui/Button/Button"; +import { errorResponse } from '@/lib/error-response'; +import { queryClient } from '@/lib/query-client'; import cn from "@/lib/utils"; function MainMenuCategoryPage() { const navigate = useNavigate(); + + const storeId = localStorage.getItem('storeId') as string; + const { data } = useQuery(categoryQueries.getCategories({ storeId })) + const { mutateAsync: createCategory } = useMutation(categoryMutations.createCategory()) + const form = useForm({ mode: "onSubmit", reValidateMode: "onChange", @@ -19,14 +33,38 @@ function MainMenuCategoryPage() { const categories = useWatch({ control: form.control, name: "categories" }); + const [isSubmitting, setIsSubmitting] = useState(false); + + useEffect(() => { + if (data) { + form.reset({ + categories: data?.map((category) => ({ + id: Number(category.categoryId), + name: category.name, + })), + }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [data]) + const handleAddCategory = () => { if (categories.at(-1)?.name === "") return; form.setValue("categories", [...categories, { id: categories.length + 1, name: "" }]); }; - const handleSubmit = form.handleSubmit(() => { - // TODO: 카테고리 저장 로직 구현 - navigate("/menus"); + const handleSubmit = form.handleSubmit(async () => { + setIsSubmitting(true); + try { + categories.forEach(async categoryInput => { + await createCategory({ storeId, data: { name: categoryInput.name } }) + }) + toast.success("카테고리 저장이 완료되었습니다."); + queryClient.invalidateQueries({ queryKey: CATEGORY_KEY.category() }) + navigate("/menus", { replace: true }); + } catch (error) { + setIsSubmitting(false); + toast.error(errorResponse(error).data.message); + } }); return ( @@ -53,7 +91,7 @@ function MainMenuCategoryPage() { ))} @@ -82,8 +120,9 @@ function MainMenuCategoryPage() { sm: { buttonSize: "sm", className: "w-full" }, }} onClick={handleSubmit} + disabled={isSubmitting} > - 확인 + {isSubmitting ? : "저장"}
From d18a15e63f8691a4f149ea5c6bc86588ea530da4 Mon Sep 17 00:00:00 2001 From: Simune <111689342+chaeyun-sim@users.noreply.github.com> Date: Mon, 26 Jan 2026 18:24:58 +0900 Subject: [PATCH 04/46] =?UTF-8?q?feat:=20MainMenuPage=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=EC=B9=B4=ED=85=8C=EA=B3=A0=EB=A6=AC=EA=B0=80=20=EC=97=86?= =?UTF-8?q?=EC=9D=84=EB=95=8C=EC=9D=98=20=EC=BB=B4=ED=8F=AC=EB=84=8C?= =?UTF-8?q?=ED=8A=B8=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/owner/menus/CategoryEmptyState.tsx | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 src/pages/main/owner/menus/CategoryEmptyState.tsx diff --git a/src/pages/main/owner/menus/CategoryEmptyState.tsx b/src/pages/main/owner/menus/CategoryEmptyState.tsx new file mode 100644 index 0000000..371586c --- /dev/null +++ b/src/pages/main/owner/menus/CategoryEmptyState.tsx @@ -0,0 +1,36 @@ +import Lottie from "lottie-react"; +import { useNavigate } from "react-router-dom"; +import successApplication from "@/assets/json/success-application.json"; +import MobileTitle from "@/components/layout/MobileTitle"; +import Button from "@/components/ui/Button/Button"; + +function CategoryEmptyState() { + const navigate = useNavigate(); + + return ( +
+ +
+ +

{`음식의 카테고리가 등록되어있지 않아요.\n아래 버튼을 눌러 카테고리를 등록해주세요.`}

+ +
+
+ ) +} + +export default CategoryEmptyState; \ No newline at end of file From 3497aaa0fa084e35556e0736251e41a33f1e8125 Mon Sep 17 00:00:00 2001 From: Simune <111689342+chaeyun-sim@users.noreply.github.com> Date: Mon, 26 Jan 2026 18:25:18 +0900 Subject: [PATCH 05/46] =?UTF-8?q?feat:=20queryOptions=EC=97=90=20select=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/categories/queries.ts | 3 ++- src/api/menus/queries.ts | 4 +++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/api/categories/queries.ts b/src/api/categories/queries.ts index e9d881e..236d28e 100644 --- a/src/api/categories/queries.ts +++ b/src/api/categories/queries.ts @@ -5,12 +5,13 @@ import type { Category } from '@/types/domain/menu'; export const categoryQueries = { getCategories: ({ storeId }: { storeId: string }) => - queryOptions<{ categories: Category[] }>({ + queryOptions<{ categories: Category[] }, Error, Category[]>({ queryKey: CATEGORY_KEY.category(), queryFn: async () => { const response = await instance.get(`/stores/${storeId}/categories`); return response.data; }, enabled: !!storeId, + select: data => data?.categories }), }; diff --git a/src/api/menus/queries.ts b/src/api/menus/queries.ts index 444fd5c..00e48d1 100644 --- a/src/api/menus/queries.ts +++ b/src/api/menus/queries.ts @@ -5,13 +5,14 @@ import type { Menu, MenuDetail } from '@/types/domain/menu'; export const menuQueries = { getMenus: ({ storeId, categoryId }: { storeId: string; categoryId: string }) => - queryOptions<{ menus: Menu[] }>({ + queryOptions<{ menus: Menu[] }, Error, Menu[]>({ queryKey: MENUS_KEY.menu(), queryFn: async () => { const response = await instance.get(`/stores/${storeId}/categories/${categoryId}/menus`); return response.data; }, enabled: !!storeId && !!categoryId, + select: data => data?.menus }), getMenuDetail: ({ storeId, menuId, categoryId }: { storeId: string; menuId: string; categoryId: string }) => queryOptions({ @@ -21,5 +22,6 @@ export const menuQueries = { return response.data; }, enabled: !!storeId && !!menuId && !!categoryId, + select: data => data }), }; From bc42f68ccd193e706aff30d7257569b26fa4e46b Mon Sep 17 00:00:00 2001 From: Simune <111689342+chaeyun-sim@users.noreply.github.com> Date: Mon, 26 Jan 2026 22:52:27 +0900 Subject: [PATCH 06/46] =?UTF-8?q?fix:=20=EB=A9=94=EB=89=B4=20query,=20muta?= =?UTF-8?q?tion=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/menus/keys.ts | 5 +++-- src/api/menus/mutations.ts | 11 ++++++----- src/api/menus/queries.ts | 4 ++-- 3 files changed, 11 insertions(+), 9 deletions(-) diff --git a/src/api/menus/keys.ts b/src/api/menus/keys.ts index cd3667f..3a9c50a 100644 --- a/src/api/menus/keys.ts +++ b/src/api/menus/keys.ts @@ -1,6 +1,7 @@ const MENU = "MENU"; export const MENUS_KEY = { - menu: () => [MENU], - menuDetail: (menuId: string) => [MENU, menuId], + menu: [MENU], + menus: (categoryId: string) => [MENU, categoryId], + menuDetail: (menuId: string) => [MENU, "detail", menuId], }; diff --git a/src/api/menus/mutations.ts b/src/api/menus/mutations.ts index 02e6547..974a63b 100644 --- a/src/api/menus/mutations.ts +++ b/src/api/menus/mutations.ts @@ -1,12 +1,13 @@ import { mutationOptions } from '@tanstack/react-query'; import { instance } from '@/api'; -import type { MenuSchema } from '@/schema/menu.schema'; import type { MoveRequest, PropsWithStoreId } from '@/types/api'; -import type { MenuOptionGroup } from '@/types/domain/menu'; +import type { MenuDetail, MenuOptionGroup } from '@/types/domain/menu'; + +type MenuDetailPayload = Omit & {menuOptionGroups: Omit[]}; export const menuMutations = { createMenu: () => mutationOptions({ - mutationFn: async ({ storeId, categoryId, data }: PropsWithStoreId<{ categoryId: string; data: {file: File, request: Omit} }>) => { + mutationFn: async ({ storeId, categoryId, data }: PropsWithStoreId<{ categoryId: string; data: { file: File, request: MenuDetailPayload; } }>) => { const formData = new FormData(); formData.append("file", data.file); formData.append("request", JSON.stringify(data.request)); @@ -20,13 +21,13 @@ export const menuMutations = { }, }), updateMenu: () => mutationOptions({ - mutationFn: async ({ storeId, menuId, data }: PropsWithStoreId<{ menuId: string; data: Omit & {menuOptionGroups: Omit[]} }>) => { + mutationFn: async ({ storeId, menuId, data }: PropsWithStoreId<{ menuId: string; data: MenuDetailPayload; }>) => { const response = await instance.put(`/stores/${storeId}/menus/${menuId}`, data); return response.data; }, }), updateMenuWithImage: () => mutationOptions({ - mutationFn: async ({ storeId, menuId, data }: PropsWithStoreId<{ menuId: string; data: {file: File, request: Omit & {menuOptionGroups: Omit[]}} }>) => { + mutationFn: async ({ storeId, menuId, data }: PropsWithStoreId<{ menuId: string; data: {file: File, request: MenuDetailPayload; } }>) => { const formData = new FormData(); formData.append("file", data.file); formData.append("request", JSON.stringify(data.request)); diff --git a/src/api/menus/queries.ts b/src/api/menus/queries.ts index 00e48d1..01d72df 100644 --- a/src/api/menus/queries.ts +++ b/src/api/menus/queries.ts @@ -6,12 +6,12 @@ import type { Menu, MenuDetail } from '@/types/domain/menu'; export const menuQueries = { getMenus: ({ storeId, categoryId }: { storeId: string; categoryId: string }) => queryOptions<{ menus: Menu[] }, Error, Menu[]>({ - queryKey: MENUS_KEY.menu(), + queryKey: MENUS_KEY.menus(categoryId), queryFn: async () => { const response = await instance.get(`/stores/${storeId}/categories/${categoryId}/menus`); return response.data; }, - enabled: !!storeId && !!categoryId, + enabled: !!storeId && !!categoryId && categoryId !== 'all', select: data => data?.menus }), getMenuDetail: ({ storeId, menuId, categoryId }: { storeId: string; menuId: string; categoryId: string }) => From 911e10fba52a029ef9f6f20a7a88a289c8b5ff3a Mon Sep 17 00:00:00 2001 From: Simune <111689342+chaeyun-sim@users.noreply.github.com> Date: Mon, 26 Jan 2026 22:53:07 +0900 Subject: [PATCH 07/46] =?UTF-8?q?feat:=20=EC=A0=84=EC=B2=B4=20=EB=A9=94?= =?UTF-8?q?=EB=89=B4=20=EC=B6=94=EC=B6=9C=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/hooks/menu/useMenu.ts | 18 ++++++++++++++---- src/pages/main/owner/menus/MainMenuPage.tsx | 13 +++++++------ 2 files changed, 21 insertions(+), 10 deletions(-) diff --git a/src/hooks/menu/useMenu.ts b/src/hooks/menu/useMenu.ts index ae60f1d..78ad8ba 100644 --- a/src/hooks/menu/useMenu.ts +++ b/src/hooks/menu/useMenu.ts @@ -1,5 +1,5 @@ import { useState } from 'react'; -import { useQuery } from '@tanstack/react-query'; +import { useQuery, useQueries } from '@tanstack/react-query'; import { categoryQueries } from '@/api/categories/queries'; import { menuQueries } from '@/api/menus/queries'; @@ -7,15 +7,25 @@ function useMenu() { const [selectedCategory, setSelectedCategory] = useState('all'); const storeId = localStorage.getItem('storeId') as string; - const getCategories = useQuery(categoryQueries.getCategories({ storeId })) - const getMenus = useQuery(menuQueries.getMenus({ storeId, categoryId: selectedCategory ?? "" })) + const getCategories = useQuery(categoryQueries.getCategories({ storeId })) + const getMenus = useQuery(menuQueries.getMenus({ storeId, categoryId: selectedCategory ?? "" })) + + const allMenusQueries = useQueries({ + queries: getCategories.data?.map(category => + menuQueries.getMenus({ storeId, categoryId: category.categoryId }) + ) ?? [], + }) + + const allMenus = selectedCategory === 'all' + ? allMenusQueries.flatMap(query => query.data ?? []) + : getMenus.data?.filter((menu) => menu.categoryId === selectedCategory) return { storeId, selectedCategory, setSelectedCategory, getCategories, - getMenus, + allMenus, } } diff --git a/src/pages/main/owner/menus/MainMenuPage.tsx b/src/pages/main/owner/menus/MainMenuPage.tsx index d89b66b..02a3a22 100644 --- a/src/pages/main/owner/menus/MainMenuPage.tsx +++ b/src/pages/main/owner/menus/MainMenuPage.tsx @@ -14,12 +14,13 @@ import CategoryModal from "@/pages/main/owner/menus/CategoryModal"; import MenuCard from "@/pages/main/owner/menus/MenuCard"; import MenuDeleteAlert from "@/pages/main/owner/menus/MenuDeleteAlert"; import MenuDetailModal from "@/pages/main/owner/menus/MenuDetailModal"; +import type { Menu } from '@/types/domain/menu'; function MainMenuPage() { const navigate = useNavigate(); const isMobile = useMediaQuery({ maxWidth: 959 }); - const { selectedCategory, setSelectedCategory, getCategories, getMenus } = useMenu() + const { selectedCategory, setSelectedCategory, getCategories, allMenus } = useMenu() const { isChangedToMenuOrder, isSubmittingOrderChange, changeToMenuOrder, saveMoves, resetMoves, addToChangeList } = useMenuMove() const { checkedMenus, toggleCheckMenu, resetCheckedMenus } = useCheckMenu() @@ -34,14 +35,14 @@ function MainMenuPage() { * 메뉴 추가 모달 열기 기능 */ const handleOpenCreateMenuModal = () => { - overlay.open((overlayProps) => ); + overlay.open((overlayProps) => ); }; /** * 메뉴 상세 모달 열기 기능 */ - const handleOpenMenuDetailModal = (menuId: string) => { - overlay.open((overlayProps) => ); + const handleOpenMenuDetailModal = (menu: Menu) => { + overlay.open((overlayProps) => ); }; /** @@ -184,7 +185,7 @@ function MainMenuPage() { )} { addToChangeList({ sourceId: String(sourceId), targetId: String(targetId), where }); }} @@ -204,7 +205,7 @@ function MainMenuPage() { state: { menuId: menu.menuId, entry: "detail" }, }); } else { - handleOpenMenuDetailModal(menu.menuId); + handleOpenMenuDetailModal(menu); } }} disabled={isChangedToMenuOrder} From 9c851137a48112f4a3377890095b438816e55dc7 Mon Sep 17 00:00:00 2001 From: Simune <111689342+chaeyun-sim@users.noreply.github.com> Date: Mon, 26 Jan 2026 22:53:28 +0900 Subject: [PATCH 08/46] =?UTF-8?q?feat:=20=EB=A9=94=EB=89=B4=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/owner/menus/MenuDeleteAlert.tsx | 44 ++++++++++++++++++- 1 file changed, 43 insertions(+), 1 deletion(-) diff --git a/src/pages/main/owner/menus/MenuDeleteAlert.tsx b/src/pages/main/owner/menus/MenuDeleteAlert.tsx index 7f15ce3..3966c81 100644 --- a/src/pages/main/owner/menus/MenuDeleteAlert.tsx +++ b/src/pages/main/owner/menus/MenuDeleteAlert.tsx @@ -1,4 +1,10 @@ +import { useMutation } from '@tanstack/react-query'; +import { toast } from 'sonner'; +import { MENUS_KEY } from '@/api/menus/keys'; +import { menuMutations } from '@/api/menus/mutations'; import Alert from "@/components/overlay/Alert"; +import { errorResponse } from '@/lib/error-response'; +import { queryClient } from '@/lib/query-client'; import type { Menu } from "@/types/domain/menu"; import type { ModalProps } from "@/types/overlay"; @@ -7,6 +13,9 @@ interface MenuDeleteAlertProps extends ModalProps { } function MenuDeleteAlert({ deleteItems, ...props }: Readonly) { + const { mutate: deleteMenu } = useMutation(menuMutations.deleteMenu()) + const { mutate: multiDeleteMenus } = useMutation(menuMutations.multiDeleteMenus()) + const renderText = () => { if (deleteItems?.length === 1) { return `을`; @@ -15,7 +24,40 @@ function MenuDeleteAlert({ deleteItems, ...props }: Readonly { - // TODO: 메뉴 삭제 로직 구현 + if (!deleteItems?.length) return; + + if (deleteItems?.length === 1) { + deleteMenu({ + storeId: localStorage.getItem('storeId') as string, + menuId: deleteItems[0].menuId, + categoryId: deleteItems[0].categoryId, + }, { + onSuccess: () => { + toast.success('메뉴 삭제가 완료되었습니다.'); + queryClient.invalidateQueries({ queryKey: MENUS_KEY.menu }) + props.close() + }, + onError: (error) => { + toast.error(errorResponse(error).data.message); + }, + }) + } else { + multiDeleteMenus({ + storeId: localStorage.getItem('storeId') as string, + data: { + menuIds: deleteItems.map((item) => item.menuId), + }, + }, { + onSuccess: () => { + toast.success('메뉴 삭제가 완료되었습니다.'); + queryClient.invalidateQueries({ queryKey: MENUS_KEY.menu }) + props.close() + }, + onError: (error) => { + toast.error(errorResponse(error).data.message); + }, + }) + } }; return ( From 796fa46eb82eb575a1bc0f63b431ce2371e6d9cf Mon Sep 17 00:00:00 2001 From: Simune <111689342+chaeyun-sim@users.noreply.github.com> Date: Mon, 26 Jan 2026 22:54:24 +0900 Subject: [PATCH 09/46] =?UTF-8?q?feat:=20=EB=A9=94=EB=89=B4=20=EB=93=B1?= =?UTF-8?q?=EB=A1=9D=20=EB=B0=8F=20=EC=88=98=EC=A0=95=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/owner/menus/MenuDetailContent.tsx | 254 +++++++++++++----- src/pages/main/owner/menus/MenuDetailForm.tsx | 12 +- 2 files changed, 194 insertions(+), 72 deletions(-) diff --git a/src/pages/main/owner/menus/MenuDetailContent.tsx b/src/pages/main/owner/menus/MenuDetailContent.tsx index ee15516..939b0a3 100644 --- a/src/pages/main/owner/menus/MenuDetailContent.tsx +++ b/src/pages/main/owner/menus/MenuDetailContent.tsx @@ -1,12 +1,19 @@ -import { useState } from "react"; +import { useEffect, useRef, useState, type ChangeEvent } from "react"; import { zodResolver } from "@hookform/resolvers/zod"; +import { useMutation } from '@tanstack/react-query'; import { useForm } from "react-hook-form"; +import { toast } from 'sonner'; +import { MENUS_KEY } from '@/api/menus/keys'; +import { menuMutations } from '@/api/menus/mutations'; import logo from "@/assets/images/logo.svg"; +import Spinner from '@/components/feedback/Spinner'; import { Form, FormErrorMessage } from "@/components/form/Form"; import { Close } from "@/components/icons"; import { Dialog } from "@/components/overlay/Dialog"; import Button from "@/components/ui/Button/Button"; import Image from "@/components/ui/Image"; +import { errorResponse } from '@/lib/error-response'; +import { queryClient } from '@/lib/query-client'; import cn from "@/lib/utils"; import MenuDetailForm from "@/pages/main/owner/menus/MenuDetailForm"; import MenuDetailOptions from "@/pages/main/owner/menus/MenuDetailOptions"; @@ -29,85 +36,188 @@ function MenuDetailContent({ close, initialCategoryId, }: Readonly) { + const imageRef = useRef(null); const [mode, setMode] = useState(entry === "create" ? "create" : "detail"); + const [imageFile, setImageFile] = useState(null); + const [isSubmitting, setIsSubmitting] = useState(false); + const isEditing = mode === "edit"; const isCreating = mode === "create"; const isDetail = mode === "detail"; - const canEdit = isCreating || isEditing; + const canEdit = (isCreating || isEditing) && !isSubmitting; const form = useForm({ resolver: zodResolver(menuSchema), mode: "onSubmit", reValidateMode: "onChange", - defaultValues: isCreating - ? { - categoryId: initialCategoryId, - name: "", - description: "", - price: "", - spicy: 0, - state: "DEFAULT", - label: "DEFAULT", - image: "", - printEnabled: true, - requiredOptionGroups: [], - optionalOptionGroups: [], - } - : { - categoryId: menu?.categoryId ?? "", - name: menu?.name ?? "", - description: menu?.description, - price: menu?.price ? menu.price.toLocaleString("ko-KR") : "", - spicy: menu?.spicy ?? 0, - state: menu?.state ?? "DEFAULT", - label: menu?.label ?? "DEFAULT", - image: menu?.image ?? "", - printEnabled: menu?.printEnabled ?? true, - requiredOptionGroups: - menu?.menuOptionGroups - .filter((group) => group.type === "MANDATORY") - .map((group) => ({ - name: group.name, - type: group.type, - printEnabled: group.printEnabled, - menuOptions: group.menuOptions.map((option) => ({ - name: option.name, - price: option.price.toLocaleString("ko-KR"), - })), - })) ?? [], - optionalOptionGroups: - menu?.menuOptionGroups - .filter((group) => group.type === "OPTIONAL") - .map((group) => ({ - name: group.name, - type: group.type, - printEnabled: group.printEnabled, - menuOptions: group.menuOptions.map((option) => ({ - name: option.name, - price: option.price.toLocaleString("ko-KR"), - })), - })) ?? [], - }, + defaultValues: { + categoryId: initialCategoryId ?? "", + name: "", + description: "", + price: "", + spicy: 0, + state: "DEFAULT", + label: "DEFAULT", + image: "", + printEnabled: true, + requiredOptionGroups: [], + optionalOptionGroups: [], + }, }); + useEffect(() => { + if (menu?.menuId) { + form.reset({ + ...menu, + price: menu?.price ? menu.price.toLocaleString("ko-KR") : "", + requiredOptionGroups: + menu?.menuOptionGroups + .filter((group) => group.type === "MANDATORY") + .map((group) => ({ + ...group, + menuOptions: group.menuOptions.map((option) => ({ + name: option.name, + price: option.price.toLocaleString("ko-KR"), + })), + })) ?? [], + optionalOptionGroups: + menu?.menuOptionGroups + .filter((group) => group.type === "OPTIONAL") + .map((group) => ({ + ...group, + menuOptions: group.menuOptions.map((option) => ({ + name: option.name, + price: option.price.toLocaleString("ko-KR"), + })), + })) ?? [], + }) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [menu]) + const [selectedGroup, setSelectedGroup] = useState("MANDATORY"); + + const { mutate: createMenu } = useMutation(menuMutations.createMenu()) + const { mutate: updateMenu } = useMutation(menuMutations.updateMenu()) + const { mutate: updateMenuWithImage } = useMutation(menuMutations.updateMenuWithImage()) + + const handleSubmit = () => { + setIsSubmitting(true); + const data = form.getValues(); + + const payload = { + ...data, + price: Number(data.price?.replaceAll(',', '')), + description: data.description ?? "", + menuOptionGroups: [ + ...data.requiredOptionGroups.map((group) => ({ + ...group, + menuOptions: group.menuOptions.map((option) => ({ + name: option.name, + price: Number(option.price?.replaceAll(',', '')), + })), + })), + ...data.optionalOptionGroups.map((group) => ({ + ...group, + menuOptions: group.menuOptions.map((option) => ({ + name: option.name, + price: Number(option.price?.replaceAll(',', '')), + })), + })), + ], + } - const handleSubmit = form.handleSubmit(() => { if (isCreating) { - // TODO: 메뉴 생성 로직 추가 - // TODO: 메뉴의 가격과 옵션의 가격을 number로 변경 - } else { - // TODO: 메뉴 수정 로직 추가 + createMenu({ + storeId: localStorage.getItem('storeId') as string, + categoryId: form.getValues('categoryId'), + data: { + file: imageFile as File, + request: payload, + }, + }, { + onSuccess: () => { + toast.success('메뉴 생성이 완료되었습니다.'); + close() + queryClient.invalidateQueries({ queryKey: MENUS_KEY.menu }) + }, + onError: (error) => { + setIsSubmitting(false); + const { data } = errorResponse(error); + + if (data.code === 'EXCEED_MAXIMUM_MENU_COUNT') { + form.setError('categoryId', { message: data.message }); + return; + } + + if (data.code === 'INVALID_DISCOUNT_OPTION_PRICE') { + form.setError('price', { message: data.message }); + return; + } + + toast.error(data.message); + }, + }) + } else { + if (menu?.image === data.image) { + updateMenu({ + storeId: localStorage.getItem('storeId') as string, + menuId: menu.menuId, + data: payload, + }, { + onSuccess: () => { + toast.success('메뉴 수정이 완료되었습니다.'); + queryClient.invalidateQueries({ queryKey: MENUS_KEY.menuDetail(menu.menuId) }) + queryClient.invalidateQueries({ queryKey: MENUS_KEY.menu }) + close() + }, + onError: (error) => { + setIsSubmitting(false); + toast.error(errorResponse(error).data.message) + }, + }) + } else { + updateMenuWithImage({ + storeId: localStorage.getItem('storeId') as string, + menuId: menu.menuId, + data: { + file: imageFile as File, + request: payload, + }, + }, { + onSuccess: () => { + toast.success('메뉴 수정이 완료되었습니다.'); + queryClient.invalidateQueries({ queryKey: MENUS_KEY.menuDetail(menu.menuId) }) + queryClient.invalidateQueries({ queryKey: MENUS_KEY.menu }) + close() + }, + onError: (error) => { + setIsSubmitting(false); + toast.error(errorResponse(error).data.message) + }, + }) + } setMode("detail"); } - }); + }; + + const handleImageChange = (e: ChangeEvent) => { + const file = e.target.files?.[0]; + if (file) { + const previewUrl = URL.createObjectURL(file); + form.setValue('image', previewUrl); + setImageFile(file); + } + }; + + if (!isCreating && !menu?.menuId) return null; return (
- -
+
+
@@ -121,17 +231,19 @@ function MenuDetailContent({
+
-
+
+
- {menu?.image && ( + {menu?.image && !isCreating && ( {menu?.name )} - {(!menu?.image || isCreating) && ( + {(!menu?.image && !form.watch('image') && isCreating) && (
)} + {imageFile && ( + 메뉴 이미지 미리보기 + )} {isCreating && ( )} + {/* TODO: 이미지 등록 로직 구현 */} {form.formState.errors.image && ( @@ -169,7 +287,7 @@ function MenuDetailContent({
{/* 메뉴 정보 폼 */} -
+
@@ -190,7 +308,7 @@ function MenuDetailContent({
-
+
{isDetail ? ( )}
- +
); } diff --git a/src/pages/main/owner/menus/MenuDetailForm.tsx b/src/pages/main/owner/menus/MenuDetailForm.tsx index 150cb7b..4178646 100644 --- a/src/pages/main/owner/menus/MenuDetailForm.tsx +++ b/src/pages/main/owner/menus/MenuDetailForm.tsx @@ -1,4 +1,6 @@ +import { useQuery } from '@tanstack/react-query'; import { useFormContext } from "react-hook-form"; +import { categoryQueries } from '@/api/categories/queries'; import { FormErrorMessage } from "@/components/form/Form"; import FormField from "@/components/form/FormField"; import Button from "@/components/ui/Button/Button"; @@ -8,7 +10,6 @@ import Label from "@/components/ui/Label"; import Switch from "@/components/ui/Switch"; import { formatPrice } from "@/lib/format"; import cn from "@/lib/utils"; -import { CATEGORIES_MOCK } from "@/pages/main/owner/menus/mock"; import type { MenuSchema } from "@/schema/menu.schema"; import type { MenuLabel, MenuState } from "@/types/domain/menu"; @@ -31,7 +32,8 @@ interface MenuDetailFormProps { } function MenuDetailForm({ canEdit, isDetail }: Readonly) { - const categories = CATEGORIES_MOCK; + const storeId = localStorage.getItem('storeId') as string; + const { data: categories } = useQuery(categoryQueries.getCategories({ storeId })) const form = useFormContext(); const currentLabel = form.watch("label"); @@ -55,13 +57,13 @@ function MenuDetailForm({ canEdit, isDetail }: Readonly) {
({ + dropdownItems={categories?.map((category) => ({ id: category.categoryId, name: category.name, - }))} + })) ?? []} value={currentCategoryId} defaultText={ - categories.find((category) => category.categoryId === currentCategoryId)?.name ?? + categories?.find((category) => category.categoryId === currentCategoryId)?.name ?? "카테고리를 선택해주세요." } disabled={!canEdit} From 6460765216c38ceb7501fad31d13592223b0f960 Mon Sep 17 00:00:00 2001 From: Simune <111689342+chaeyun-sim@users.noreply.github.com> Date: Mon, 26 Jan 2026 22:54:44 +0900 Subject: [PATCH 10/46] =?UTF-8?q?feat:=20=EB=A9=94=EB=89=B4=20=EC=83=81?= =?UTF-8?q?=EC=84=B8=20api=20=EC=97=B0=EA=B2=B0=20=EB=B0=8F=20css=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/main/owner/menus/MenuDetailModal.tsx | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/src/pages/main/owner/menus/MenuDetailModal.tsx b/src/pages/main/owner/menus/MenuDetailModal.tsx index c9ff31e..87af824 100644 --- a/src/pages/main/owner/menus/MenuDetailModal.tsx +++ b/src/pages/main/owner/menus/MenuDetailModal.tsx @@ -1,24 +1,27 @@ +import { useQuery } from '@tanstack/react-query'; +import { menuQueries } from '@/api/menus/queries'; import { Dialog } from "@/components/overlay/Dialog"; import MenuDetailContent from "@/pages/main/owner/menus/MenuDetailContent"; -import { MENU_DETAILS_MOCK } from "@/pages/main/owner/menus/mock"; import type { ModalProps } from "@/types/overlay"; interface MenuDetailModalProps extends ModalProps { menuId?: string; entry: "create" | "detail"; - initialCategoryId?: string; + initialCategoryId: string; } -function MenuDetailModal({ menuId, entry, ...props }: Readonly) { - const menu = MENU_DETAILS_MOCK.find((menu) => menu.menuId === menuId) ?? MENU_DETAILS_MOCK[0]; +function MenuDetailModal({ menuId, entry, initialCategoryId, ...props }: Readonly) { + const storeId = localStorage.getItem('storeId') as string; + const { data: menu } = useQuery(menuQueries.getMenuDetail({ storeId, menuId: menuId as string, categoryId: initialCategoryId })) ?? null; return ( !open && props.close()}> - + 메뉴 정보 + ); From 1f4eb3a39b18c46ac209ddeeb26f1d8f92556372 Mon Sep 17 00:00:00 2001 From: Simune <111689342+chaeyun-sim@users.noreply.github.com> Date: Mon, 26 Jan 2026 22:54:58 +0900 Subject: [PATCH 11/46] =?UTF-8?q?fix:=20=EB=A9=94=EB=89=B4=20=EC=B9=B4?= =?UTF-8?q?=EB=93=9C=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/main/owner/menus/MenuCard.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/pages/main/owner/menus/MenuCard.tsx b/src/pages/main/owner/menus/MenuCard.tsx index 9c32966..43f0b29 100644 --- a/src/pages/main/owner/menus/MenuCard.tsx +++ b/src/pages/main/owner/menus/MenuCard.tsx @@ -1,5 +1,6 @@ import loginBg from "@/assets/images/login-bg-md@2x.webp"; import Checkbox from "@/components/ui/Checkbox"; +import Image from '@/components/ui/Image'; import cn from "@/lib/utils"; import type { Menu } from "@/types/domain/menu"; @@ -49,7 +50,7 @@ function MenuCard({ tabIndex={0} onClick={onClick} > - {menu.name} + {menu.name} {!disabled && (
Date: Mon, 26 Jan 2026 22:55:11 +0900 Subject: [PATCH 12/46] =?UTF-8?q?fix:=20Rootlayout=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/main/RootLayout.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/pages/main/RootLayout.tsx b/src/pages/main/RootLayout.tsx index cc15b6c..b7676e9 100644 --- a/src/pages/main/RootLayout.tsx +++ b/src/pages/main/RootLayout.tsx @@ -10,7 +10,9 @@ function RootLayout() { const location = useLocation(); const isGuest = location.pathname.startsWith("/guest"); - const { data: stores, isLoading } = useQuery(storesQueries.getStores()); + const { data: stores, isLoading, isError } = useQuery(storesQueries.getStores()); + + if (isLoading || isError) return null; if ((stores?.stores?.length ?? 0) === 0 && !isGuest) { return ; @@ -20,8 +22,6 @@ function RootLayout() { return ; } - if (isLoading) return null; - return ( {isGuest ? ( From d81a7ed7a6019775588b539b625f52e66ec453f7 Mon Sep 17 00:00:00 2001 From: Simune <111689342+chaeyun-sim@users.noreply.github.com> Date: Tue, 27 Jan 2026 11:40:01 +0900 Subject: [PATCH 13/46] =?UTF-8?q?feat:=20=EB=A9=94=EB=89=B4=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20=EB=B0=8F=20=EC=88=98=EC=A0=95=20=ED=95=A8=EC=88=98?= =?UTF-8?q?=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/hooks/menu/useMenuFormSubmit.ts | 147 ++++++++++++++++++ .../main/owner/menus/MenuDetailContent.tsx | 125 ++------------- 2 files changed, 157 insertions(+), 115 deletions(-) create mode 100644 src/hooks/menu/useMenuFormSubmit.ts diff --git a/src/hooks/menu/useMenuFormSubmit.ts b/src/hooks/menu/useMenuFormSubmit.ts new file mode 100644 index 0000000..4edc234 --- /dev/null +++ b/src/hooks/menu/useMenuFormSubmit.ts @@ -0,0 +1,147 @@ +import { useState } from 'react'; +import { useMutation } from '@tanstack/react-query'; +import { toast } from 'sonner'; +import { MENUS_KEY } from '@/api/menus/keys'; +import { menuMutations } from '@/api/menus/mutations'; +import { errorResponse } from '@/lib/error-response'; +import { queryClient } from '@/lib/query-client'; +import type { MenuSchema } from '@/schema/menu.schema'; +import type { MenuDetail } from '@/types/domain/menu'; +import type { UseFormReturn } from 'react-hook-form'; + +interface UseMenuFormSubmitProps { + form: UseFormReturn; + menu: MenuDetail; + isCreating: boolean; + close: () => void; +} + +function useMenuFormSubmit({ form, menu, isCreating, close }: UseMenuFormSubmitProps) { + const { mutate: createMenu } = useMutation(menuMutations.createMenu()) + const { mutate: updateMenu } = useMutation(menuMutations.updateMenu()) + const { mutate: updateMenuWithImage } = useMutation(menuMutations.updateMenuWithImage()) + + const [isSubmitting, setIsSubmitting] = useState(false); + + const getPayload = (data: MenuSchema) => { + return { + ...data, + price: Number(data.price?.replaceAll(',', '')), + description: data.description ?? "", + menuOptionGroups: [ + ...data.requiredOptionGroups.map((group) => ({ + ...group, + menuOptions: group.menuOptions.map((option) => ({ + name: option.name, + price: Number(option.price?.replaceAll(',', '')), + })), + })), + ...data.optionalOptionGroups.map((group) => ({ + ...group, + menuOptions: group.menuOptions.map((option) => ({ + name: option.name, + price: Number(option.price?.replaceAll(',', '')), + })), + })), + ], + } + } + + const handleUpdateMenu = (imageFile: File | null) => { + const data = form.getValues(); + + if (menu?.image === data.image) { + updateMenu({ + storeId: localStorage.getItem('storeId') as string, + menuId: menu.menuId, + data: getPayload(data), + }, { + onSuccess: () => { + toast.success('메뉴가 수정되었습니다.'); + queryClient.invalidateQueries({ queryKey: MENUS_KEY.menuDetail(menu.menuId) }) + queryClient.invalidateQueries({ queryKey: MENUS_KEY.menu }) + close() + }, + onError: (error) => { + setIsSubmitting(false); + toast.error(errorResponse(error).data.message) + }, + }) + } else { + if (!imageFile) return; + + updateMenuWithImage({ + storeId: localStorage.getItem('storeId') as string, + menuId: menu.menuId, + data: { + file: imageFile, + request: getPayload(data), + }, + }, { + onSuccess: () => { + toast.success('메뉴가 수정되었습니다.'); + queryClient.invalidateQueries({ queryKey: MENUS_KEY.menuDetail(menu.menuId) }) + queryClient.invalidateQueries({ queryKey: MENUS_KEY.menu }) + close() + }, + onError: (error) => { + setIsSubmitting(false); + toast.error(errorResponse(error).data.message) + }, + }) + } + } + + const handleCreateMenu = (imageFile: File | null) => { + if (!imageFile) return; + + createMenu({ + storeId: localStorage.getItem('storeId') as string, + categoryId: form.getValues('categoryId'), + data: { + file: imageFile, + request: getPayload(form.getValues()), + }, + }, { + onSuccess: () => { + toast.success('메뉴 생성이 완료되었습니다.'); + close() + queryClient.invalidateQueries({ queryKey: MENUS_KEY.menu }) + }, + onError: (error) => { + setIsSubmitting(false); + const { data } = errorResponse(error); + + if (data.code === 'EXCEED_MAXIMUM_MENU_COUNT') { + form.setError('categoryId', { message: data.message }); + return; + } + + if (data.code === 'INVALID_DISCOUNT_OPTION_PRICE') { + form.setError('price', { message: data.message }); + return; + } + + toast.error(data.message); + }, + }) + } + + const handleSubmit = (imageFile: File | null) => { + if (isSubmitting) return; + setIsSubmitting(true); + + if (isCreating) { + handleCreateMenu(imageFile); + } else { + handleUpdateMenu(imageFile); + } + } + + return { + isSubmitting, + handleSubmit, + }; +} + +export default useMenuFormSubmit; \ No newline at end of file diff --git a/src/pages/main/owner/menus/MenuDetailContent.tsx b/src/pages/main/owner/menus/MenuDetailContent.tsx index 939b0a3..12513a1 100644 --- a/src/pages/main/owner/menus/MenuDetailContent.tsx +++ b/src/pages/main/owner/menus/MenuDetailContent.tsx @@ -1,10 +1,6 @@ import { useEffect, useRef, useState, type ChangeEvent } from "react"; import { zodResolver } from "@hookform/resolvers/zod"; -import { useMutation } from '@tanstack/react-query'; import { useForm } from "react-hook-form"; -import { toast } from 'sonner'; -import { MENUS_KEY } from '@/api/menus/keys'; -import { menuMutations } from '@/api/menus/mutations'; import logo from "@/assets/images/logo.svg"; import Spinner from '@/components/feedback/Spinner'; import { Form, FormErrorMessage } from "@/components/form/Form"; @@ -12,8 +8,7 @@ import { Close } from "@/components/icons"; import { Dialog } from "@/components/overlay/Dialog"; import Button from "@/components/ui/Button/Button"; import Image from "@/components/ui/Image"; -import { errorResponse } from '@/lib/error-response'; -import { queryClient } from '@/lib/query-client'; +import useMenuFormSubmit from '@/hooks/menu/useMenuFormSubmit'; import cn from "@/lib/utils"; import MenuDetailForm from "@/pages/main/owner/menus/MenuDetailForm"; import MenuDetailOptions from "@/pages/main/owner/menus/MenuDetailOptions"; @@ -38,16 +33,12 @@ function MenuDetailContent({ }: Readonly) { const imageRef = useRef(null); const [mode, setMode] = useState(entry === "create" ? "create" : "detail"); - const [imageFile, setImageFile] = useState(null); - const [isSubmitting, setIsSubmitting] = useState(false); const isEditing = mode === "edit"; const isCreating = mode === "create"; const isDetail = mode === "detail"; - const canEdit = (isCreating || isEditing) && !isSubmitting; - const form = useForm({ resolver: zodResolver(menuSchema), mode: "onSubmit", @@ -98,110 +89,16 @@ function MenuDetailContent({ }, [menu]) const [selectedGroup, setSelectedGroup] = useState("MANDATORY"); - - const { mutate: createMenu } = useMutation(menuMutations.createMenu()) - const { mutate: updateMenu } = useMutation(menuMutations.updateMenu()) - const { mutate: updateMenuWithImage } = useMutation(menuMutations.updateMenuWithImage()) - - const handleSubmit = () => { - setIsSubmitting(true); - const data = form.getValues(); - - const payload = { - ...data, - price: Number(data.price?.replaceAll(',', '')), - description: data.description ?? "", - menuOptionGroups: [ - ...data.requiredOptionGroups.map((group) => ({ - ...group, - menuOptions: group.menuOptions.map((option) => ({ - name: option.name, - price: Number(option.price?.replaceAll(',', '')), - })), - })), - ...data.optionalOptionGroups.map((group) => ({ - ...group, - menuOptions: group.menuOptions.map((option) => ({ - name: option.name, - price: Number(option.price?.replaceAll(',', '')), - })), - })), - ], - } - if (isCreating) { - createMenu({ - storeId: localStorage.getItem('storeId') as string, - categoryId: form.getValues('categoryId'), - data: { - file: imageFile as File, - request: payload, - }, - }, { - onSuccess: () => { - toast.success('메뉴 생성이 완료되었습니다.'); - close() - queryClient.invalidateQueries({ queryKey: MENUS_KEY.menu }) - }, - onError: (error) => { - setIsSubmitting(false); - const { data } = errorResponse(error); - - if (data.code === 'EXCEED_MAXIMUM_MENU_COUNT') { - form.setError('categoryId', { message: data.message }); - return; - } + const { isSubmitting, handleSubmit } = useMenuFormSubmit({ + form, + menu, + isCreating, + close, + }); - if (data.code === 'INVALID_DISCOUNT_OPTION_PRICE') { - form.setError('price', { message: data.message }); - return; - } + const canEdit = (isCreating || isEditing) && !isSubmitting; - toast.error(data.message); - }, - }) - } else { - if (menu?.image === data.image) { - updateMenu({ - storeId: localStorage.getItem('storeId') as string, - menuId: menu.menuId, - data: payload, - }, { - onSuccess: () => { - toast.success('메뉴 수정이 완료되었습니다.'); - queryClient.invalidateQueries({ queryKey: MENUS_KEY.menuDetail(menu.menuId) }) - queryClient.invalidateQueries({ queryKey: MENUS_KEY.menu }) - close() - }, - onError: (error) => { - setIsSubmitting(false); - toast.error(errorResponse(error).data.message) - }, - }) - } else { - updateMenuWithImage({ - storeId: localStorage.getItem('storeId') as string, - menuId: menu.menuId, - data: { - file: imageFile as File, - request: payload, - }, - }, { - onSuccess: () => { - toast.success('메뉴 수정이 완료되었습니다.'); - queryClient.invalidateQueries({ queryKey: MENUS_KEY.menuDetail(menu.menuId) }) - queryClient.invalidateQueries({ queryKey: MENUS_KEY.menu }) - close() - }, - onError: (error) => { - setIsSubmitting(false); - toast.error(errorResponse(error).data.message) - }, - }) - } - setMode("detail"); - } - }; const handleImageChange = (e: ChangeEvent) => { const file = e.target.files?.[0]; @@ -240,7 +137,7 @@ function MenuDetailContent({ {menu?.name )} {(!menu?.image && !form.watch('image') && isCreating) && ( @@ -279,8 +176,6 @@ function MenuDetailContent({ )} - {/* TODO: 이미지 등록 로직 구현 */} - {form.formState.errors.image && ( {form.formState.errors.image.message} )} @@ -334,7 +229,7 @@ function MenuDetailContent({ sm: { buttonSize: "sm", className: "w-full h-10!" }, }} disabled={isSubmitting} - onClick={handleSubmit} + onClick={() => handleSubmit(imageFile)} > {isSubmitting ? : "저장하기"} From 65a9265867198b4c4caf5c88f6ef6f63d95553aa Mon Sep 17 00:00:00 2001 From: Simune <111689342+chaeyun-sim@users.noreply.github.com> Date: Tue, 27 Jan 2026 11:40:56 +0900 Subject: [PATCH 14/46] =?UTF-8?q?feat:=20Skeleton=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/feedback/Skeleton.tsx | 12 ++++++++++++ src/pages/main/owner/menus/MainMenuPage.tsx | 19 ++++++++++++------- 2 files changed, 24 insertions(+), 7 deletions(-) diff --git a/src/components/feedback/Skeleton.tsx b/src/components/feedback/Skeleton.tsx index 7b748f6..b27bd8d 100644 --- a/src/components/feedback/Skeleton.tsx +++ b/src/components/feedback/Skeleton.tsx @@ -1,4 +1,6 @@ import type { ComponentProps, PropsWithChildren } from "react"; +import type { ButtonProps } from '@/components/ui/Button/Button'; +import { buttonVariants } from '@/components/ui/Button/Button.styles'; import cn from "@/lib/utils"; function Skeleton({ className, ...props }: ComponentProps<"div">) { @@ -43,9 +45,19 @@ function SkeletonFieldGroup({ total }: Readonly<{ total: number }>) { ); } +function SkeletonButton({ className, buttonProps, ...props }: { buttonProps: ButtonProps; className?: string; } & ComponentProps<"div">) { + return ( + + ); +} + Skeleton.Wrapper = SkeletonWrapper; Skeleton.Input = SkeletonInput; Skeleton.Label = SkeletonLabel; Skeleton.FieldGroup = SkeletonFieldGroup; +Skeleton.Button = SkeletonButton; export { Skeleton }; diff --git a/src/pages/main/owner/menus/MainMenuPage.tsx b/src/pages/main/owner/menus/MainMenuPage.tsx index 02a3a22..53b5428 100644 --- a/src/pages/main/owner/menus/MainMenuPage.tsx +++ b/src/pages/main/owner/menus/MainMenuPage.tsx @@ -2,6 +2,7 @@ import { rectSortingStrategy } from "@dnd-kit/sortable"; import { overlay } from "overlay-kit"; import useMediaQuery from "react-responsive"; import { useNavigate } from "react-router-dom"; +import { Skeleton } from '@/components/feedback/Skeleton'; import { Plus, Settings, Trash, UpsideDown } from "@/components/icons"; import MobileTitle from "@/components/layout/MobileTitle"; import Button from "@/components/ui/Button/Button"; @@ -20,10 +21,10 @@ function MainMenuPage() { const navigate = useNavigate(); const isMobile = useMediaQuery({ maxWidth: 959 }); - const { selectedCategory, setSelectedCategory, getCategories, allMenus } = useMenu() + const { selectedCategory, setSelectedCategory, getCategories, menus, setMenus } = useMenu() const { isChangedToMenuOrder, isSubmittingOrderChange, changeToMenuOrder, saveMoves, resetMoves, addToChangeList } = useMenuMove() const { checkedMenus, toggleCheckMenu, resetCheckedMenus } = useCheckMenu() - + /** * 카테고리 모달 열기 기능 */ @@ -78,6 +79,9 @@ function MainMenuPage() {
메뉴 관리
+ {/* TODO: */} + +
- {[{ categoryId: "all", name: "전체" }, ...getCategories.data ?? []].map((category) => { + {[...getCategories.data ?? []].map((category) => { const isSelected = selectedCategory === category.categoryId; return ( @@ -185,8 +189,9 @@ function MainMenuPage() { )} { + items={menus} + onReorder={(items, sourceId, targetId, where) => { + setMenus(items) addToChangeList({ sourceId: String(sourceId), targetId: String(targetId), where }); }} canDrag={isChangedToMenuOrder} From d7e7096a0a2d7655ad78308b0dcdb21755d40e9c Mon Sep 17 00:00:00 2001 From: Simune <111689342+chaeyun-sim@users.noreply.github.com> Date: Tue, 27 Jan 2026 11:41:21 +0900 Subject: [PATCH 15/46] =?UTF-8?q?feat:=20=EB=A9=94=EB=89=B4=20=EC=88=9C?= =?UTF-8?q?=EC=84=9C=20=EB=B3=80=EA=B2=BD=20=EC=BD=94=EB=93=9C=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/hooks/menu/useMenu.ts | 38 ++++++++++++++++++++++++++++++++------ 1 file changed, 32 insertions(+), 6 deletions(-) diff --git a/src/hooks/menu/useMenu.ts b/src/hooks/menu/useMenu.ts index 78ad8ba..c242fed 100644 --- a/src/hooks/menu/useMenu.ts +++ b/src/hooks/menu/useMenu.ts @@ -1,15 +1,22 @@ -import { useState } from 'react'; +import { useMemo, useState } from 'react'; import { useQuery, useQueries } from '@tanstack/react-query'; import { categoryQueries } from '@/api/categories/queries'; import { menuQueries } from '@/api/menus/queries'; +import type { Menu } from '@/types/domain/menu'; function useMenu() { - const [selectedCategory, setSelectedCategory] = useState('all'); - const storeId = localStorage.getItem('storeId') as string; const getCategories = useQuery(categoryQueries.getCategories({ storeId })) - const getMenus = useQuery(menuQueries.getMenus({ storeId, categoryId: selectedCategory ?? "" })) + const [manuallySelectedCategory, setManuallySelectedCategory] = useState(null); + const [manuallyOrderedMenus, setManuallyOrderedMenus] = useState(null); + + const selectedCategory = useMemo(() => { + return manuallySelectedCategory ?? getCategories.data?.[0]?.categoryId ?? ''; + }, [manuallySelectedCategory, getCategories.data]); + + const getMenus = useQuery(menuQueries.getMenus({ storeId, categoryId: selectedCategory ?? "" })) + const allMenusQueries = useQueries({ queries: getCategories.data?.map(category => menuQueries.getMenus({ storeId, categoryId: category.categoryId }) @@ -20,12 +27,31 @@ function useMenu() { ? allMenusQueries.flatMap(query => query.data ?? []) : getMenus.data?.filter((menu) => menu.categoryId === selectedCategory) + const menus = useMemo(() => { + return manuallyOrderedMenus ?? allMenus ?? []; + }, [manuallyOrderedMenus, allMenus]); + + const setMenus = (newMenus: Menu[]) => { + setManuallyOrderedMenus(newMenus); + } + + const handleSetSelectedCategory = (categoryId: string) => { + setManuallySelectedCategory(categoryId); + setManuallyOrderedMenus(null); + } + + const resetManuallyOrderedMenus = () => { + setManuallyOrderedMenus(null); + } + return { storeId, selectedCategory, - setSelectedCategory, + setSelectedCategory: handleSetSelectedCategory, getCategories, - allMenus, + menus, + setMenus, + resetManuallyOrderedMenus, } } From 97102baf146fa26d78a715a83cc828e7d733f662 Mon Sep 17 00:00:00 2001 From: Simune <111689342+chaeyun-sim@users.noreply.github.com> Date: Tue, 27 Jan 2026 12:57:02 +0900 Subject: [PATCH 16/46] =?UTF-8?q?feat:=20=EC=BB=B4=ED=8F=AC=EB=84=8C?= =?UTF-8?q?=ED=8A=B8=EC=97=90=20Skeleton=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/ui/Button/Button.types.ts | 4 + src/components/ui/Button/ResponsiveButton.tsx | 6 +- src/pages/main/owner/menus/MainMenuPage.tsx | 12 +-- src/pages/main/owner/menus/MenuCard.tsx | 93 ++++++++++--------- 4 files changed, 64 insertions(+), 51 deletions(-) diff --git a/src/components/ui/Button/Button.types.ts b/src/components/ui/Button/Button.types.ts index 49f4d66..94f243c 100644 --- a/src/components/ui/Button/Button.types.ts +++ b/src/components/ui/Button/Button.types.ts @@ -61,6 +61,10 @@ interface ResponsiveButtonProps extends BaseButtonProps { * - 전체 사이즈에 적용되는 공통 스타일 */ commonClassName?: string; + /** + * 버튼 로딩 여부 + */ + isLoading?: boolean; } export { diff --git a/src/components/ui/Button/ResponsiveButton.tsx b/src/components/ui/Button/ResponsiveButton.tsx index ad21dc0..f6e05b3 100644 --- a/src/components/ui/Button/ResponsiveButton.tsx +++ b/src/components/ui/Button/ResponsiveButton.tsx @@ -18,6 +18,7 @@ function ResponsiveButton( color = ColorName.PRIMARY, asChild = false, children, + isLoading, ...buttonProps }: ResponsiveButtonProps, ref: React.ForwardedRef @@ -69,13 +70,14 @@ function ResponsiveButton( }), hideButton(screenSize as ScreenSize), buttonStyle(buttonConfig?.buttonSize ?? "md", buttonConfig?.className ?? ""), - commonClassName + commonClassName, + isLoading && "animate-pulse rounded-md bg-gray-700 w-14!" )} disabled={disabled} type={buttonProps.type ?? "button"} {...buttonProps} > - {children} + {isLoading ? null : children} ); })} diff --git a/src/pages/main/owner/menus/MainMenuPage.tsx b/src/pages/main/owner/menus/MainMenuPage.tsx index 53b5428..f25f9ac 100644 --- a/src/pages/main/owner/menus/MainMenuPage.tsx +++ b/src/pages/main/owner/menus/MainMenuPage.tsx @@ -2,7 +2,6 @@ import { rectSortingStrategy } from "@dnd-kit/sortable"; import { overlay } from "overlay-kit"; import useMediaQuery from "react-responsive"; import { useNavigate } from "react-router-dom"; -import { Skeleton } from '@/components/feedback/Skeleton'; import { Plus, Settings, Trash, UpsideDown } from "@/components/icons"; import MobileTitle from "@/components/layout/MobileTitle"; import Button from "@/components/ui/Button/Button"; @@ -79,9 +78,6 @@ function MainMenuPage() {
메뉴 관리
- {/* TODO: */} - -
@@ -130,6 +127,7 @@ function MainMenuPage() { }} onClick={() => handleCategoryChange(category.categoryId)} disabled={isChangedToMenuOrder} + isLoading={getCategories.isLoading} > {category.name} @@ -146,10 +144,11 @@ function MainMenuPage() { className="button-sm" disabled={isSubmittingOrderChange} onClick={saveMoves} + isLoading={getCategories.isLoading} > {isSubmittingOrderChange ? "저장중..." : "저장"} -
@@ -173,7 +172,7 @@ function MainMenuPage() { )}
- {!isChangedToMenuOrder && ( + {!isChangedToMenuOrder && !getCategories.isLoading && ( )} - > - {MENU_LABEL_TRANSLATE[menu.label]} - - )} - {menu.state !== "DEFAULT" && ( - + {menu.state !== "DEFAULT" && ( + + )} +
)} +
+ + {menu.name} + + + {menu.price.toLocaleString()}원 + +
- )} -
- - {menu.name} - - - {menu.price.toLocaleString()}원 -
-
-
+ + )}
); } From eeaa0806d5bcb7b87f45fb0a40631b65244aaaac Mon Sep 17 00:00:00 2001 From: Simune <111689342+chaeyun-sim@users.noreply.github.com> Date: Tue, 27 Jan 2026 21:26:28 +0900 Subject: [PATCH 17/46] =?UTF-8?q?feat:=20=EB=A9=94=EB=89=B4=20=EC=9D=B4?= =?UTF-8?q?=EB=AF=B8=EC=A7=80=20lazy=20loading=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/ui/Image.tsx | 71 ++++++++++++++-- src/pages/main/owner/menus/MainMenuPage.tsx | 4 +- src/pages/main/owner/menus/MenuCard.tsx | 93 ++++++++++----------- 3 files changed, 109 insertions(+), 59 deletions(-) diff --git a/src/components/ui/Image.tsx b/src/components/ui/Image.tsx index 5dc785a..3fb242c 100644 --- a/src/components/ui/Image.tsx +++ b/src/components/ui/Image.tsx @@ -1,10 +1,16 @@ -import { type ImgHTMLAttributes, type ReactEventHandler, useState } from "react"; +import { + type ImgHTMLAttributes, + type ReactEventHandler, + useEffect, + useRef, + useState, +} from "react"; +import cn from "@/lib/utils"; interface ImageProps extends ImgHTMLAttributes { src: string; alt: string; fallbackSrc?: string; - fill?: boolean; } export default function Image({ @@ -13,14 +19,43 @@ export default function Image({ onError, fallbackSrc, alt, + className, ...props }: Readonly) { + const wrapperRef = useRef(null); + + const [visible, setVisible] = useState(false); + const [loaded, setLoaded] = useState(false); const [useFallback, setUseFallback] = useState(false); + const [error, setError] = useState(false); + + useEffect(() => { + const observer = new IntersectionObserver( + ([entry]) => { + if (entry.isIntersecting) { + setVisible(true); + observer.disconnect(); + } + }, + { rootMargin: "200px" } + ); + + if (wrapperRef.current) observer.observe(wrapperRef.current); - const cdnSrc = src && src?.trim() !== "" ? `${import.meta.env.VITE_PUBLIC_CDN}/${src}` : ""; - const currentSrc = useFallback && fallbackSrc ? fallbackSrc : cdnSrc; + return () => observer.disconnect(); + }, []); + + const cdnSrc = + src && src.trim() !== "" ? `${import.meta.env.VITE_PUBLIC_CDN}/${src}` : ""; + + const currentSrc = () => { + if (!visible) return ""; + if (!src || error || (useFallback && fallbackSrc)) return fallbackSrc; + return cdnSrc; + }; const handleError: ReactEventHandler = (e) => { + setError(true); if (!useFallback) { setUseFallback(true); if (!fallbackSrc) { @@ -29,9 +64,29 @@ export default function Image({ } }; - if (!currentSrc || currentSrc.trim() === "") { - return null; - } + return ( +
+ {/* Skeleton */} + {!loaded && visible && ( +
+ )} - return {alt}; + {visible && ( + {alt} setLoaded(true)} + draggable={false} + className={cn( + "h-full w-full object-cover transition-all duration-600", + loaded ? "blur-0 scale-100" : "blur-sm scale-[1.03]", + className + )} + {...props} + /> + )} +
+ ); } diff --git a/src/pages/main/owner/menus/MainMenuPage.tsx b/src/pages/main/owner/menus/MainMenuPage.tsx index f25f9ac..6f48fac 100644 --- a/src/pages/main/owner/menus/MainMenuPage.tsx +++ b/src/pages/main/owner/menus/MainMenuPage.tsx @@ -196,7 +196,7 @@ function MainMenuPage() { canDrag={isChangedToMenuOrder} keyExtractor={(item) => item.menuId} strategy={rectSortingStrategy} - renderItem={(menu) => ( + renderItem={(menu, index) => ( )} /> diff --git a/src/pages/main/owner/menus/MenuCard.tsx b/src/pages/main/owner/menus/MenuCard.tsx index 67943e0..68b8153 100644 --- a/src/pages/main/owner/menus/MenuCard.tsx +++ b/src/pages/main/owner/menus/MenuCard.tsx @@ -24,7 +24,7 @@ interface MenuCardProps { onClick: () => void; className?: string; disabled?: boolean; - isLoading?: boolean; + index: number; } function MenuCard({ @@ -34,7 +34,7 @@ function MenuCard({ onClick, className, disabled, - isLoading, + index, }: Readonly) { return (
{ if (e.key === "Enter" || e.key === " ") { @@ -53,56 +52,52 @@ function MenuCard({ tabIndex={0} onClick={onClick} > - {isLoading ? null : ( - <> - {menu.name} - {!disabled && ( -
e.stopPropagation()} - onKeyDown={(e) => e.stopPropagation()} - > - -
- )} -
-
- {(menu.state !== "DEFAULT" || menu.label !== "DEFAULT") && ( -
- {menu.label !== "DEFAULT" && ( - - )} - {menu.state !== "DEFAULT" && ( - + {menu.name} + {!disabled && ( +
e.stopPropagation()} + onKeyDown={(e) => e.stopPropagation()} + > + +
+ )} +
+
+ {(menu.state !== "DEFAULT" || menu.label !== "DEFAULT") && ( +
+ {menu.label !== "DEFAULT" && ( + + )} + {menu.state !== "DEFAULT" && ( + )} -
- - {menu.name} - - - {menu.price.toLocaleString()}원 - -
+ )} +
+ + {menu.name} + + + {menu.price.toLocaleString()}원 +
- - )} +
+
); } From 49768abac75c989c1348877f5a19b275b3fa915c Mon Sep 17 00:00:00 2001 From: Simune <111689342+chaeyun-sim@users.noreply.github.com> Date: Tue, 27 Jan 2026 23:10:07 +0900 Subject: [PATCH 18/46] =?UTF-8?q?fix:=20=EC=88=9C=EC=84=9C=EB=B3=80?= =?UTF-8?q?=EA=B2=BD=20=EC=8B=9C=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20=EC=9E=AC?= =?UTF-8?q?=EB=A0=8C=EB=8D=94=EB=A7=81=20=EB=B2=84=EA=B7=B8=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/ui/Drag/DragList.tsx | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/components/ui/Drag/DragList.tsx b/src/components/ui/Drag/DragList.tsx index 916eef9..d8b9ced 100644 --- a/src/components/ui/Drag/DragList.tsx +++ b/src/components/ui/Drag/DragList.tsx @@ -92,9 +92,9 @@ export function DragList({ ? [restrictToVerticalAxis, restrictToParentElement] : [restrictToParentElement]; - return canDrag ? ( + return ( ({ - ) : ( - <>{items.map((item, index) => renderItem(item, index))} ); } From 1e4349e6d12c9a6407986fc76fb6696412235644 Mon Sep 17 00:00:00 2001 From: Simune <111689342+chaeyun-sim@users.noreply.github.com> Date: Tue, 27 Jan 2026 23:11:16 +0900 Subject: [PATCH 19/46] =?UTF-8?q?feat:=20=EC=B9=B4=ED=85=8C=EA=B3=A0?= =?UTF-8?q?=EB=A6=AC=20=EB=B2=84=ED=8A=BC=20hover=20=EC=8B=9C=20=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=84=B0=20prefetch=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/main/owner/menus/MainMenuPage.tsx | 24 +++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/src/pages/main/owner/menus/MainMenuPage.tsx b/src/pages/main/owner/menus/MainMenuPage.tsx index 6f48fac..484b5c1 100644 --- a/src/pages/main/owner/menus/MainMenuPage.tsx +++ b/src/pages/main/owner/menus/MainMenuPage.tsx @@ -2,6 +2,7 @@ import { rectSortingStrategy } from "@dnd-kit/sortable"; import { overlay } from "overlay-kit"; import useMediaQuery from "react-responsive"; import { useNavigate } from "react-router-dom"; +import { menuQueries } from '@/api/menus/queries'; import { Plus, Settings, Trash, UpsideDown } from "@/components/icons"; import MobileTitle from "@/components/layout/MobileTitle"; import Button from "@/components/ui/Button/Button"; @@ -9,6 +10,7 @@ import { DragList } from "@/components/ui/Drag/DragList"; import useCheckMenu from '@/hooks/menu/useCheckMenu'; import useMenu from '@/hooks/menu/useMenu'; import useMenuMove from '@/hooks/menu/useMenuMove'; +import { queryClient } from '@/lib/query-client'; import CategoryEmptyState from '@/pages/main/owner/menus/CategoryEmptyState'; import CategoryModal from "@/pages/main/owner/menus/CategoryModal"; import MenuCard from "@/pages/main/owner/menus/MenuCard"; @@ -52,6 +54,17 @@ function MainMenuPage() { overlay.open((overlayProps) => ); }; + /** + * 카테고리 데이터 미리 가져오기 기능 + * @param categoryId 카테고리 ID + */ + const handlePrefetch = (categoryId: string) => { + if (isChangedToMenuOrder) return; + if (categoryId === selectedCategory) return; + const storeId = localStorage.getItem('storeId') as string; + queryClient.prefetchQuery(menuQueries.getMenus({ storeId, categoryId })) + } + /** * 현재 카테고리 변경 기능 */ @@ -93,13 +106,13 @@ function MainMenuPage() { className: "rounded-xl shrink-0 size-9 bg-gray-700 items-center justify-center p-0", }, sm: { - buttonSize: "xl", - className: "rounded-xl shrink-0 size-9 bg-gray-700 items-center justify-center p-0", + buttonSize: "custom", + className: "rounded-xl shrink-0 size-10 bg-gray-700 items-center justify-center p-0", }, }} onClick={() => !isChangedToMenuOrder && handleOpenCategoryModal()} disabled={isChangedToMenuOrder} - isLoading={getCategories.isLoading} + aria-label="카테고리 설정 버튼" > @@ -126,8 +139,8 @@ function MainMenuPage() { }, }} onClick={() => handleCategoryChange(category.categoryId)} + onMouseEnter={() => handlePrefetch(category.categoryId)} disabled={isChangedToMenuOrder} - isLoading={getCategories.isLoading} > {category.name} @@ -144,11 +157,10 @@ function MainMenuPage() { className="button-sm" disabled={isSubmittingOrderChange} onClick={saveMoves} - isLoading={getCategories.isLoading} > {isSubmittingOrderChange ? "저장중..." : "저장"} -
From 7600b446478470baff3f82dabfc74e54e4bb389e Mon Sep 17 00:00:00 2001 From: Simune <111689342+chaeyun-sim@users.noreply.github.com> Date: Tue, 27 Jan 2026 23:13:12 +0900 Subject: [PATCH 20/46] =?UTF-8?q?feat:=20aria-label=20=EC=B6=94=EA=B0=80?= =?UTF-8?q?=20=EB=B0=8F=20Image=20loading=20props=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/layout/SectionTitle.tsx | 1 + src/pages/main/owner/menus/MenuCard.tsx | 11 ++++++++++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/src/components/layout/SectionTitle.tsx b/src/components/layout/SectionTitle.tsx index 764556a..fc3d6f3 100644 --- a/src/components/layout/SectionTitle.tsx +++ b/src/components/layout/SectionTitle.tsx @@ -47,6 +47,7 @@ function SectionTitle() { ref={buttonRef} className="center rounded-xl border border-gray-400 md:h-8 md:w-8 lg:h-12 lg:w-12 lg:rounded-2xl" onClick={() => setIsOpenUser((prev) => !prev)} + aria-label="사용자 메뉴 버튼" > diff --git a/src/pages/main/owner/menus/MenuCard.tsx b/src/pages/main/owner/menus/MenuCard.tsx index 68b8153..7d8449b 100644 --- a/src/pages/main/owner/menus/MenuCard.tsx +++ b/src/pages/main/owner/menus/MenuCard.tsx @@ -52,13 +52,22 @@ function MenuCard({ tabIndex={0} onClick={onClick} > - {menu.name} + {menu.name} {!disabled && (
e.stopPropagation()} onKeyDown={(e) => e.stopPropagation()} + aria-label="메뉴 선택 체크박스" > Date: Tue, 27 Jan 2026 23:14:04 +0900 Subject: [PATCH 21/46] =?UTF-8?q?feat:=20queryClient=EC=97=90=20staleTime?= =?UTF-8?q?=EA=B3=BC=20gcTime=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/lib/query-client.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/lib/query-client.ts b/src/lib/query-client.ts index 0bb09fa..1710890 100644 --- a/src/lib/query-client.ts +++ b/src/lib/query-client.ts @@ -4,6 +4,8 @@ export const queryClient = new QueryClient({ defaultOptions: { queries: { refetchOnWindowFocus: false, + staleTime: 1000 * 60 * 5, + gcTime: 1000 * 60 * 10, }, }, }); From 1cad7811ffb267dd379dac16608d25c694292853 Mon Sep 17 00:00:00 2001 From: Simune <111689342+chaeyun-sim@users.noreply.github.com> Date: Tue, 27 Jan 2026 23:14:37 +0900 Subject: [PATCH 22/46] =?UTF-8?q?fix:=20Button=20loading=20props=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/ui/Button/Button.types.ts | 4 ---- src/components/ui/Button/ResponsiveButton.tsx | 4 +--- 2 files changed, 1 insertion(+), 7 deletions(-) diff --git a/src/components/ui/Button/Button.types.ts b/src/components/ui/Button/Button.types.ts index 94f243c..49f4d66 100644 --- a/src/components/ui/Button/Button.types.ts +++ b/src/components/ui/Button/Button.types.ts @@ -61,10 +61,6 @@ interface ResponsiveButtonProps extends BaseButtonProps { * - 전체 사이즈에 적용되는 공통 스타일 */ commonClassName?: string; - /** - * 버튼 로딩 여부 - */ - isLoading?: boolean; } export { diff --git a/src/components/ui/Button/ResponsiveButton.tsx b/src/components/ui/Button/ResponsiveButton.tsx index f6e05b3..9b59a30 100644 --- a/src/components/ui/Button/ResponsiveButton.tsx +++ b/src/components/ui/Button/ResponsiveButton.tsx @@ -18,7 +18,6 @@ function ResponsiveButton( color = ColorName.PRIMARY, asChild = false, children, - isLoading, ...buttonProps }: ResponsiveButtonProps, ref: React.ForwardedRef @@ -71,13 +70,12 @@ function ResponsiveButton( hideButton(screenSize as ScreenSize), buttonStyle(buttonConfig?.buttonSize ?? "md", buttonConfig?.className ?? ""), commonClassName, - isLoading && "animate-pulse rounded-md bg-gray-700 w-14!" )} disabled={disabled} type={buttonProps.type ?? "button"} {...buttonProps} > - {isLoading ? null : children} + {children} ); })} From 8d4074dc8693061eea58d360dbbe0c12029b572e Mon Sep 17 00:00:00 2001 From: Simune <111689342+chaeyun-sim@users.noreply.github.com> Date: Tue, 27 Jan 2026 23:37:45 +0900 Subject: [PATCH 23/46] =?UTF-8?q?fix:=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/ui/Image.tsx | 25 +++++++++++++++---- src/pages/main/owner/menus/MenuCard.tsx | 1 + .../main/owner/menus/MenuDetailContent.tsx | 3 ++- 3 files changed, 23 insertions(+), 6 deletions(-) diff --git a/src/components/ui/Image.tsx b/src/components/ui/Image.tsx index 3fb242c..0e26a23 100644 --- a/src/components/ui/Image.tsx +++ b/src/components/ui/Image.tsx @@ -11,6 +11,8 @@ interface ImageProps extends ImgHTMLAttributes { src: string; alt: string; fallbackSrc?: string; + hasBlur?: boolean; + imageClassName?: string; } export default function Image({ @@ -20,6 +22,8 @@ export default function Image({ fallbackSrc, alt, className, + hasBlur = false, + imageClassName, ...props }: Readonly) { const wrapperRef = useRef(null); @@ -64,11 +68,22 @@ export default function Image({ } }; + const blurClassName = () => { + if (hasBlur) { + if (loaded) return "blur-0 scale-100"; + return "blur-sm scale-[1.03]"; + } + return ""; + }; + return ( -
+
{/* Skeleton */} - {!loaded && visible && ( -
+ {!loaded && visible && hasBlur && ( +
)} {visible && ( @@ -81,8 +96,8 @@ export default function Image({ draggable={false} className={cn( "h-full w-full object-cover transition-all duration-600", - loaded ? "blur-0 scale-100" : "blur-sm scale-[1.03]", - className + blurClassName(), + imageClassName, )} {...props} /> diff --git a/src/pages/main/owner/menus/MenuCard.tsx b/src/pages/main/owner/menus/MenuCard.tsx index 7d8449b..905e02f 100644 --- a/src/pages/main/owner/menus/MenuCard.tsx +++ b/src/pages/main/owner/menus/MenuCard.tsx @@ -60,6 +60,7 @@ function MenuCard({ fallbackSrc={loginBg} fetchPriority={index < 5 ? "high" : "auto"} loading={index < 5 ? "eager" : "lazy"} + hasBlur /> {!disabled && (
)} {(!menu?.image && !form.watch('image') && isCreating) && ( From d60b164f2e2acaefa8ea2c1d9af4d875a36d2c02 Mon Sep 17 00:00:00 2001 From: Simune <111689342+chaeyun-sim@users.noreply.github.com> Date: Wed, 28 Jan 2026 12:18:10 +0900 Subject: [PATCH 24/46] =?UTF-8?q?feat:=20=EC=82=AC=EC=9D=B4=EB=93=9C?= =?UTF-8?q?=EB=B0=94=20=EB=A1=9C=EA=B3=A0=20=ED=81=AC=EA=B8=B0=20=EA=B3=A0?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/layout/Sidebar.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index f5a4e15..42eaeaa 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -66,6 +66,10 @@ function Sidebar({ onLinkClick }: Readonly) { src={logoTextHorizontal} alt="logo text horizontal" className="w-39.5 md:w-37 lg:w-55" + width="221" + height="60" + loading="eager" + fetchPriority="high" />
From 7196822f53f3850c08c9da028bd5124c3e5617d7 Mon Sep 17 00:00:00 2001 From: Simune <111689342+chaeyun-sim@users.noreply.github.com> Date: Wed, 28 Jan 2026 12:21:49 +0900 Subject: [PATCH 25/46] =?UTF-8?q?fix:=20=EB=A1=9C=EA=B7=B8=EC=95=84?= =?UTF-8?q?=EC=9B=83=20=EA=B5=AC=ED=98=84,=20=EC=9D=B4=EB=A9=94=EC=9D=BC?= =?UTF-8?q?=20=ED=91=9C=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/layout/SectionTitle.tsx | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/src/components/layout/SectionTitle.tsx b/src/components/layout/SectionTitle.tsx index fc3d6f3..fd4e920 100644 --- a/src/components/layout/SectionTitle.tsx +++ b/src/components/layout/SectionTitle.tsx @@ -1,15 +1,20 @@ import { useRef, useState } from "react"; -import { useLocation } from "react-router-dom"; +import { useQuery } from '@tanstack/react-query'; +import { useLocation, useNavigate } from "react-router-dom"; +import { accountQueries } from '@/api/account/queries'; import { User } from "@/components/icons"; import useOutsideClick from "@/hooks/useOutSideClick"; function SectionTitle() { + const navigate = useNavigate(); const location = useLocation(); const buttonRef = useRef(null); const dropdownRef = useRef(null); const [isOpenUser, setIsOpenUser] = useState(false); + const { data: account } = useQuery(accountQueries.getMe()) + useOutsideClick({ ref: dropdownRef, handler: () => setIsOpenUser(false), @@ -32,10 +37,10 @@ function SectionTitle() { if (!getText()) return null; - const email = "asdf@gmail.com"; - const handleLogout = () => { - // TODO: 로그아웃 로직 + localStorage.removeItem("token"); + localStorage.removeItem("storeId"); + navigate("/login"); setIsOpenUser(false); }; @@ -49,7 +54,7 @@ function SectionTitle() { onClick={() => setIsOpenUser((prev) => !prev)} aria-label="사용자 메뉴 버튼" > - + {isOpenUser && (
@@ -57,14 +62,14 @@ function SectionTitle() {
- {email} + {account?.email}
)} From ad02f3d409d76719d4bc27c06ad0a0d5c0922462 Mon Sep 17 00:00:00 2001 From: Simune <111689342+chaeyun-sim@users.noreply.github.com> Date: Wed, 28 Jan 2026 12:22:27 +0900 Subject: [PATCH 26/46] =?UTF-8?q?fix:=20account=20queries=EC=97=90=20selec?= =?UTF-8?q?t=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/account/queries.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/api/account/queries.ts b/src/api/account/queries.ts index 4eb5eef..926b9e2 100644 --- a/src/api/account/queries.ts +++ b/src/api/account/queries.ts @@ -16,6 +16,7 @@ export const accountQueries = { const response = await instance.get(`/accounts/me`); return response.data; }, + select: data => data }), getAccountByPhoneNumber: (phoneNumber: string) => queryOptions({ @@ -24,5 +25,6 @@ export const accountQueries = { const response = await instance.get(`/accounts/phone-number/${phoneNumber}/me`); return response.data; }, + select: data => data }), }; From 95de785a7d473007c35fddb4f44a9e55bf87834d Mon Sep 17 00:00:00 2001 From: Simune <111689342+chaeyun-sim@users.noreply.github.com> Date: Wed, 28 Jan 2026 21:23:55 +0900 Subject: [PATCH 27/46] =?UTF-8?q?style:=20=EC=BD=94=EB=93=9C=20=ED=8F=AC?= =?UTF-8?q?=EB=A7=B7=ED=8C=85=20=EB=B0=8F=20=EC=9C=84=EC=B9=98=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/owner/menus/MenuDetailContent.tsx | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/src/pages/main/owner/menus/MenuDetailContent.tsx b/src/pages/main/owner/menus/MenuDetailContent.tsx index 17aa334..06a5913 100644 --- a/src/pages/main/owner/menus/MenuDetailContent.tsx +++ b/src/pages/main/owner/menus/MenuDetailContent.tsx @@ -32,12 +32,12 @@ function MenuDetailContent({ initialCategoryId, }: Readonly) { const imageRef = useRef(null); + const [mode, setMode] = useState(entry === "create" ? "create" : "detail"); const [imageFile, setImageFile] = useState(null); + const [selectedGroup, setSelectedGroup] = useState("MANDATORY"); - const isEditing = mode === "edit"; const isCreating = mode === "create"; - const isDetail = mode === "detail"; const form = useForm({ resolver: zodResolver(menuSchema), @@ -63,8 +63,7 @@ function MenuDetailContent({ form.reset({ ...menu, price: menu?.price ? menu.price.toLocaleString("ko-KR") : "", - requiredOptionGroups: - menu?.menuOptionGroups + requiredOptionGroups: menu?.menuOptionGroups .filter((group) => group.type === "MANDATORY") .map((group) => ({ ...group, @@ -73,8 +72,7 @@ function MenuDetailContent({ price: option.price.toLocaleString("ko-KR"), })), })) ?? [], - optionalOptionGroups: - menu?.menuOptionGroups + optionalOptionGroups: menu?.menuOptionGroups .filter((group) => group.type === "OPTIONAL") .map((group) => ({ ...group, @@ -88,8 +86,6 @@ function MenuDetailContent({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [menu]) - const [selectedGroup, setSelectedGroup] = useState("MANDATORY"); - const { isSubmitting, handleSubmit } = useMenuFormSubmit({ form, menu, @@ -97,7 +93,7 @@ function MenuDetailContent({ close, }); - const canEdit = (isCreating || isEditing) && !isSubmitting; + const canEdit = mode !== "detail" && !isSubmitting; const handleImageChange = (e: ChangeEvent) => { @@ -184,7 +180,7 @@ function MenuDetailContent({ {/* 메뉴 정보 폼 */}
- +
{/* 메뉴 옵션 폼 */} @@ -205,7 +201,7 @@ function MenuDetailContent({
- {isDetail ? ( + {mode === 'detail' ? (