diff --git a/apps/client/src/shared/components/optionsMenuButton/OptionsMenuButton.tsx b/apps/client/src/shared/components/optionsMenuButton/OptionsMenuButton.tsx index 9c7ddc86..ab30056e 100644 --- a/apps/client/src/shared/components/optionsMenuButton/OptionsMenuButton.tsx +++ b/apps/client/src/shared/components/optionsMenuButton/OptionsMenuButton.tsx @@ -9,7 +9,7 @@ export interface OptionsMenuButtonProps { const ITEM_STYLE = 'body4-r text-font-black-1 h-[3.6rem] w-full ' + - 'flex items-center pl-[0.8rem] ' + + 'flex items-center justify-center ' + 'hover:bg-gray100 focus-visible:bg-gray100 active:bg-gray200 ' + 'outline-none transition-colors'; diff --git a/apps/client/src/shared/components/sidebar/PopupPortal.tsx b/apps/client/src/shared/components/sidebar/PopupPortal.tsx index a5e30df1..636ba598 100644 --- a/apps/client/src/shared/components/sidebar/PopupPortal.tsx +++ b/apps/client/src/shared/components/sidebar/PopupPortal.tsx @@ -1,16 +1,24 @@ import { createPortal } from 'react-dom'; -import { Popup } from '@pinback/design-system/ui'; +import { useEffect, useState } from 'react'; +import { AutoDismissToast, Popup, Toast } from '@pinback/design-system/ui'; import type { PopupState } from '@shared/hooks/useCategoryPopups'; interface Props { - popup: PopupState; + popup: PopupState | null; onClose: () => void; onChange?: (value: string) => void; onCreateConfirm?: () => void; onEditConfirm?: (id: number, draft?: string) => void; onDeleteConfirm?: (id: number) => void; + categoryList?: { id: number; name: string }[]; + isToastOpen?: boolean; + onToastClose?: () => void; + toastKey?: number; + toastAction?: 'create' | 'edit' | 'delete'; } +const MAX_LEN = 10; + export default function PopupPortal({ popup, onClose, @@ -18,9 +26,74 @@ export default function PopupPortal({ onCreateConfirm, onEditConfirm, onDeleteConfirm, + categoryList, + isToastOpen, + onToastClose, + toastKey, + toastAction, }: Props) { + const [draft, setDraft] = useState(''); + + useEffect(() => { + if (!popup) return; + setDraft(popup.kind === 'edit' ? (popup.name ?? '') : ''); + }, [popup]); + if (!popup) return null; + const value = draft.trim(); + const len = value.length; + + const isEmpty = popup.kind !== 'delete' && len === 0; + const isDuplicate = + popup.kind !== 'delete' && + !!categoryList?.some( + (c) => c.name === value && (popup.kind === 'create' || c.id !== popup.id) + ); + + let helperText = ''; + let isErrorUI = false; + + if (!isEmpty && popup.kind !== 'delete') { + if (isDuplicate) { + helperText = '이미 존재하는 카테고리 이름입니다.'; + isErrorUI = true; + } else if (len > MAX_LEN) { + helperText = `카테고리 이름은 ${MAX_LEN}자 이내로 입력해주세요.`; + isErrorUI = true; + } else if (len === MAX_LEN) { + helperText = `최대 ${MAX_LEN}자까지 입력할 수 있어요.`; + isErrorUI = false; + } + } + + const handleInputChange = (next: string) => { + setDraft(next); + onChange?.(next); + }; + + const blocked = + popup.kind !== 'delete' && (isEmpty || isDuplicate || len > MAX_LEN); + + const handleCreate = () => { + if (blocked) return; + onCreateConfirm?.(); + }; + + const handleEdit = () => { + if (blocked || popup.kind !== 'edit') return; + onEditConfirm?.(popup.id, value); + }; + + const handleDelete = () => { + if (popup.kind !== 'delete') return; + onDeleteConfirm?.(popup.id); + }; + + const action = toastAction ?? (popup.kind as 'create' | 'edit' | 'delete'); + const actionLabel = + action === 'create' ? '추가' : action === 'edit' ? '수정' : '삭제'; + return createPortal(
@@ -31,10 +104,13 @@ export default function PopupPortal({ title="카테고리 추가하기" left="취소" right="추가" - onInputChange={onChange} + isError={isErrorUI} + helperText={helperText} + inputValue={draft} + onInputChange={handleInputChange} placeholder="카테고리 제목을 입력해주세요" onLeftClick={onClose} - onRightClick={() => onCreateConfirm?.()} + onRightClick={handleCreate} /> )} @@ -44,10 +120,12 @@ export default function PopupPortal({ title="카테고리 수정하기" left="취소" right="확인" - onInputChange={onChange} - defaultValue={popup.name} + isError={isErrorUI} + helperText={helperText} + inputValue={draft} + onInputChange={handleInputChange} onLeftClick={onClose} - onRightClick={() => onEditConfirm?.(popup.id)} + onRightClick={handleEdit} /> )} @@ -59,9 +137,22 @@ export default function PopupPortal({ left="취소" right="삭제" onLeftClick={onClose} - onRightClick={() => onDeleteConfirm?.(popup.id)} + onRightClick={handleDelete} /> )} + + {isToastOpen && ( +
+ + + +
+ )}
, document.body diff --git a/apps/client/src/shared/components/sidebar/Sidebar.tsx b/apps/client/src/shared/components/sidebar/Sidebar.tsx index 103cfa33..d9ccc71f 100644 --- a/apps/client/src/shared/components/sidebar/Sidebar.tsx +++ b/apps/client/src/shared/components/sidebar/Sidebar.tsx @@ -17,11 +17,13 @@ import { usePutCategory, useDeleteCategory, } from '@shared/apis/queries'; -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { useQueryClient } from '@tanstack/react-query'; export function Sidebar() { const [newCategoryName, setNewCategoryName] = useState(''); + const [toastIsOpen, setToastIsOpen] = useState(false); + const queryClient = useQueryClient(); const { data: categories } = useGetDashboardCategories(); @@ -58,6 +60,10 @@ export function Sidebar() { setNewCategoryName(name); }; + useEffect(() => { + setToastIsOpen(false); + }, [popup]); + const handleCreateCategory = () => { createCategory(newCategoryName, { onSuccess: () => { @@ -65,11 +71,12 @@ export function Sidebar() { queryClient.invalidateQueries({ queryKey: ['dashboardCategories'] }); close(); }, - onError: (error) => { - console.error('카테고리 생성 실패:', error); + onError: () => { + setToastIsOpen(true); }, }); }; + const handlePatchCategory = (id: number) => { patchCategory( { id, categoryName: newCategoryName }, @@ -79,7 +86,9 @@ export function Sidebar() { queryClient.invalidateQueries({ queryKey: ['dashboardCategories'] }); close(); }, - onError: (error) => console.error('카테고리 수정 실패:', error), + onError: () => { + setToastIsOpen(true); + }, } ); }; @@ -90,12 +99,17 @@ export function Sidebar() { queryClient.invalidateQueries({ queryKey: ['dashboardCategories'] }); close(); }, - onError: (error) => { - console.error('카테고리 삭제 실패:', error); + onError: () => { + setToastIsOpen(true); }, }); }; + const handlePopupClose = () => { + setToastIsOpen(false); + close(); + }; + if (isPending) return
; if (isError) return
; const acornCount = data.acornCount; @@ -149,7 +163,12 @@ export function Sidebar() { /> ))} - + { + setToastIsOpen(false); + openCreate(); + }} + /> @@ -158,8 +177,14 @@ export function Sidebar() { style={style ?? undefined} categoryId={menu.categoryId} getCategoryName={getCategoryName} - onEdit={(id, name) => openEdit(id, name)} - onDelete={(id, name) => openDelete(id, name)} + onEdit={(id, name) => { + setToastIsOpen(false); + openEdit(id, name); + }} + onDelete={(id, name) => { + setToastIsOpen(false); + openDelete(id, name); + }} onClose={closeMenu} containerRef={containerRef} /> @@ -180,11 +205,14 @@ export function Sidebar() { handlePatchCategory(id)} onDeleteConfirm={(id) => handleDeleteCategory(id)} + categoryList={categories?.categories ?? []} + isToastOpen={toastIsOpen} + onToastClose={() => setToastIsOpen(false)} /> );