-
{name}
-
위시 {wishCount}개
+
+
+
+
+
{name}
+
위시 {wishCount}개
+
+
+
+
-
+
);
}
diff --git a/apps/web/src/app/wish/_components/FolderEmptyState.tsx b/apps/web/src/app/wish/_components/FolderEmptyState.tsx
new file mode 100644
index 00000000..ef56563b
--- /dev/null
+++ b/apps/web/src/app/wish/_components/FolderEmptyState.tsx
@@ -0,0 +1,25 @@
+import BasketGuestIcon from '@/assets/images/basket-guest.svg';
+import Button from '@/components/button';
+
+type FolderEmptyStateProps = {
+ onAdd: () => void;
+};
+
+function FolderEmptyState({ onAdd }: FolderEmptyStateProps) {
+ return (
+
+
+
+ 아직 생성한 폴더가 없어요.
+
+ 폴더를 생성하고 위시를 관리해보세요.
+
+
+
+
+ );
+}
+
+export default FolderEmptyState;
diff --git a/apps/web/src/app/wish/_components/FolderMenu.tsx b/apps/web/src/app/wish/_components/FolderMenu.tsx
new file mode 100644
index 00000000..4f46057a
--- /dev/null
+++ b/apps/web/src/app/wish/_components/FolderMenu.tsx
@@ -0,0 +1,60 @@
+'use client';
+
+import { useRef } from 'react';
+
+import { FolderIconFill, ThreeDotVerticalIconFill, TrashIconFill } from '@/assets/icons';
+import { Popover, PopoverClose, PopoverContent, PopoverTrigger } from '@/components/popover';
+
+type FolderMenuProps = {
+ folderName: string;
+ onRename: () => void;
+ onDelete: () => void;
+};
+
+const MENU_ITEM_CLASS_NAME =
+ 'flex w-full cursor-pointer items-center gap-2 rounded-lg px-3 py-3 body-2-medium text-text-neutral-primary hover:bg-bg-layer-basement focus-visible:bg-bg-layer-basement focus-visible:outline-none';
+
+function FolderMenu({ folderName, onRename, onDelete }: FolderMenuProps) {
+ const isItemSelectedRef = useRef(false);
+
+ return (
+
+
+
+
+ {/** NOTE: 항목 선택으로 닫힐 때 트리거로 포커스를 되돌리면 이어 열리는 시트 입력창의 autoFocus 를 뺏음 */}
+ {
+ if (isItemSelectedRef.current) event.preventDefault();
+ isItemSelectedRef.current = false;
+ }}
+ className="w-[191px] p-2 ring-0"
+ >
+ {
+ isItemSelectedRef.current = true;
+ onRename();
+ }}
+ >
+
+ 폴더명 변경하기
+
+ {
+ isItemSelectedRef.current = true;
+ onDelete();
+ }}
+ >
+
+ 삭제하기
+
+
+
+ );
+}
+
+export default FolderMenu;
diff --git a/apps/web/src/app/wish/_components/FolderNameSheet.tsx b/apps/web/src/app/wish/_components/FolderNameSheet.tsx
new file mode 100644
index 00000000..14b029c4
--- /dev/null
+++ b/apps/web/src/app/wish/_components/FolderNameSheet.tsx
@@ -0,0 +1,37 @@
+'use client';
+
+import { Drawer, DrawerContent, DrawerDescription } from '@/components/drawer';
+
+import FolderNameForm from '../_common/_components/FolderNameForm';
+
+type FolderNameSheetProps = {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ title: string;
+ initialName?: string;
+ onSubmit: (name: string) => void;
+};
+
+function FolderNameSheet({
+ open,
+ onOpenChange,
+ title,
+ initialName,
+ onSubmit,
+}: FolderNameSheetProps) {
+ return (
+
+
+ 폴더 이름을 입력하세요.
+ onOpenChange(false)}
+ onSubmit={onSubmit}
+ />
+
+
+ );
+}
+
+export default FolderNameSheet;
diff --git a/apps/web/src/app/wish/_components/FolderTabContent.tsx b/apps/web/src/app/wish/_components/FolderTabContent.tsx
index 15cdd02a..c9d83c60 100644
--- a/apps/web/src/app/wish/_components/FolderTabContent.tsx
+++ b/apps/web/src/app/wish/_components/FolderTabContent.tsx
@@ -1,32 +1,99 @@
'use client';
+import { josa } from 'es-hangul';
+import { useState } from 'react';
+
+import ConfirmDialog from '@/components/common/confirm-dialog';
import { useScrollRestoration } from '@/hooks/useScrollRestoration';
import { SCROLL_NAMESPACE } from '@/utils/scrollRestoration';
-import { MOCK_FOLDERS, getMockWishImageUrl } from '../_common/_consts/wishMock';
+import { getMockWishImageUrl } from '../_common/_consts/wishMock';
+import type { WishFolderT } from '../_common/_types/wish';
import { WISH_TAB } from '../_consts/wishTab';
+import type { FolderTabContentProps } from '../_types/wishTab';
import FolderAddCard from './FolderAddCard';
import FolderCard from './FolderCard';
+import FolderEmptyState from './FolderEmptyState';
+import FolderNameSheet from './FolderNameSheet';
-function FolderTabContent() {
+function FolderTabContent({ folders, onCreate, onRename, onDelete }: FolderTabContentProps) {
useScrollRestoration({ namespace: SCROLL_NAMESPACE.WISH, scope: WISH_TAB.FOLDER });
+ const [isCreateSheetOpen, setIsCreateSheetOpen] = useState(false);
+ const [renameTarget, setRenameTarget] = useState
(null);
+ const [isRenameSheetOpen, setIsRenameSheetOpen] = useState(false);
+ const [deleteTarget, setDeleteTarget] = useState(null);
+ const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
+
+ const handleCreate = (name: string) => {
+ onCreate(name);
+ setIsCreateSheetOpen(false);
+ };
+
+ const handleRename = (name: string) => {
+ if (renameTarget) onRename(renameTarget.id, name);
+ setIsRenameSheetOpen(false);
+ };
+
+ const handleDelete = () => {
+ if (deleteTarget) onDelete(deleteTarget.id);
+ setIsDeleteDialogOpen(false);
+ };
+
return (
-
- -
-
-
- {MOCK_FOLDERS.map(({ id, name, wishIds }) => (
- -
-
-
- ))}
-
+ <>
+ {folders.length === 0 ? (
+ setIsCreateSheetOpen(true)} />
+ ) : (
+
+ -
+ setIsCreateSheetOpen(true)} />
+
+ {folders.map(folder => (
+ -
+ {
+ setRenameTarget(folder);
+ setIsRenameSheetOpen(true);
+ }}
+ onDelete={() => {
+ setDeleteTarget(folder);
+ setIsDeleteDialogOpen(true);
+ }}
+ />
+
+ ))}
+
+ )}
+
+
+
+
+ >
);
}
diff --git a/apps/web/src/app/wish/_components/WishAddCard.tsx b/apps/web/src/app/wish/_components/WishAddCard.tsx
new file mode 100644
index 00000000..453d2ac9
--- /dev/null
+++ b/apps/web/src/app/wish/_components/WishAddCard.tsx
@@ -0,0 +1,18 @@
+import { AddIconOutline } from '@/assets/icons';
+
+/** TODO: 위시 담기 동작 연결 */
+function WishAddCard() {
+ return (
+
+ );
+}
+
+export default WishAddCard;
diff --git a/apps/web/src/app/wish/_components/WishListContent.tsx b/apps/web/src/app/wish/_components/WishListContent.tsx
index c5cd5cc6..333dc213 100644
--- a/apps/web/src/app/wish/_components/WishListContent.tsx
+++ b/apps/web/src/app/wish/_components/WishListContent.tsx
@@ -1,24 +1,38 @@
'use client';
-import { useState } from 'react';
+import { useSearchParams } from 'next/navigation';
+import { useEffect, useState } from 'react';
import { toast } from 'sonner';
import { Z_INDEX } from '@/consts/zIndex';
+import { cn } from '@/utils/cn';
+import { getScrollContainer } from '@/utils/scrollRestoration';
import WishEditActionBar from '../_common/_components/WishEditActionBar';
import WishEditHeader from '../_common/_components/WishEditHeader';
-import WishSearchLink from '../_common/_components/WishSearchLink';
-import { MOCK_WISHES } from '../_common/_consts/wishMock';
+import { MOCK_FOLDERS, MOCK_WISHES } from '../_common/_consts/wishMock';
import { WISH_MODE } from '../_common/_consts/wishMode';
import { useWishEditMode } from '../_common/_hooks/useWishEditMode';
import type { WishFolderT } from '../_common/_types/wish';
+import { WISH_TAB } from '../_consts/wishTab';
+import { parseWishTabParam } from '../_utils/wishSearchParams';
+import WishSearch from './WishSearch';
import WishTabBar from './WishTabBar';
import WishTabContent from './WishTabContent';
import WishTabSummary from './WishTabSummary';
+const TITLE_HEIGHT_WITH_GAP = 52;
+
function WishListContent() {
+ const searchParams = useSearchParams();
+ const isFolderTab = parseWishTabParam(searchParams.get('tab')) === WISH_TAB.FOLDER;
+
/** TEMP: API 연동 시 위시 목록 조회 응답으로 교체 */
const [wishes, setWishes] = useState(MOCK_WISHES);
+ /** TEMP: API 연동 시 폴더 목록 조회·생성·수정·삭제 API 로 교체 */
+ const [folders, setFolders] = useState(MOCK_FOLDERS);
+ const [isSearchOpen, setIsSearchOpen] = useState(false);
+ const [isTitleHidden, setIsTitleHidden] = useState(false);
const {
mode,
selectedWishIds,
@@ -41,28 +55,73 @@ function WishListContent() {
toast.success(`상품 ${selectedWishIds.size}개를 삭제했어요.`);
};
+ const handleCreateFolder = (name: string) =>
+ setFolders(prev => [...prev, { id: Date.now(), name, wishIds: [] }]);
+
+ const handleRenameFolder = (folderId: number, name: string) =>
+ setFolders(prev => prev.map(folder => (folder.id === folderId ? { ...folder, name } : folder)));
+
+ const handleDeleteFolder = (folderId: number) =>
+ setFolders(prev => prev.filter(({ id }) => id !== folderId));
+
+ /** 스크롤 시 제목 부분을 숨김/노출 처리 함 */
+ useEffect(() => {
+ const scroller = getScrollContainer();
+ if (!scroller) return;
+
+ const handleScroll = () => {
+ if (scroller.scrollTop > TITLE_HEIGHT_WITH_GAP) setIsTitleHidden(true);
+ else if (scroller.scrollTop <= 0) setIsTitleHidden(false);
+ };
+ scroller.addEventListener('scroll', handleScroll, { passive: true });
+ return () => scroller.removeEventListener('scroll', handleScroll);
+ }, []);
+
return (
<>
{mode === WISH_MODE.EDIT ? (
) : (
<>
-
-
내 위시리스트
-
+
+
+ 내 위시리스트
+
>
)}
-
+
setIsSearchOpen(true)}
+ />
{mode === WISH_MODE.EDIT && (
@@ -70,8 +129,14 @@ function WishListContent() {
selectedCount={selectedWishIds.size}
onMove={handleMove}
onDelete={handleDelete}
+ deleteConfirm={{
+ title: '상품을 정말 삭제할까요?',
+ description: '삭제 후에는 다시 복구할 수 없어요.',
+ }}
/>
)}
+
+ {isSearchOpen &&
setIsSearchOpen(false)} />}
>
);
}
diff --git a/apps/web/src/app/wish/_components/WishSearch.tsx b/apps/web/src/app/wish/_components/WishSearch.tsx
new file mode 100644
index 00000000..67e8bbfa
--- /dev/null
+++ b/apps/web/src/app/wish/_components/WishSearch.tsx
@@ -0,0 +1,147 @@
+'use client';
+
+import { type FormEvent, useRef, useState } from 'react';
+
+import { ChevronBackwardIconFill, CloseCircularIconFill } from '@/assets/icons';
+import BasketGuestIcon from '@/assets/images/basket-guest.svg';
+import { Dialog, DialogContent, DialogDescription, DialogTitle } from '@/components/dialog';
+import { Header } from '@/components/header';
+import Input from '@/components/input';
+import { cn } from '@/utils/cn';
+
+import WishGrid from '../_common/_components/WishGrid';
+import type { WishListItemT } from '../_common/_types/wish';
+
+type WishSearchProps = {
+ wishes: WishListItemT[];
+ onClose: () => void;
+};
+
+function WishSearch({ wishes, onClose }: WishSearchProps) {
+ const inputRef = useRef(null);
+
+ const [keyword, setKeyword] = useState('');
+ const [submittedKeyword, setSubmittedKeyword] = useState(null);
+ const [isFocused, setIsFocused] = useState(true);
+
+ /** TODO: API 연동 시 위시 검색 API 로 교체 */
+ const results =
+ submittedKeyword === null
+ ? []
+ : wishes.filter(({ item }) =>
+ item.name?.toLowerCase().includes(submittedKeyword.toLowerCase())
+ );
+ const isSearching = isFocused || submittedKeyword === null;
+
+ const handleSubmit = (event: FormEvent) => {
+ event.preventDefault();
+ const trimmedKeyword = keyword.trim();
+ if (!trimmedKeyword) return;
+
+ setSubmittedKeyword(trimmedKeyword);
+ inputRef.current?.blur();
+ };
+
+ const handleClear = () => {
+ setKeyword('');
+ inputRef.current?.focus();
+ };
+
+ return (
+
+ );
+}
+
+export default WishSearch;
diff --git a/apps/web/src/app/wish/_components/WishTabBar.tsx b/apps/web/src/app/wish/_components/WishTabBar.tsx
index ed45e32d..db0b47af 100644
--- a/apps/web/src/app/wish/_components/WishTabBar.tsx
+++ b/apps/web/src/app/wish/_components/WishTabBar.tsx
@@ -1,9 +1,7 @@
'use client';
import { useSearchParams } from 'next/navigation';
-import type { ComponentType, SVGProps } from 'react';
-import { FolderIconOutline, HeartIconOutline } from '@/assets/icons';
import { ROUTES } from '@/consts/route';
import { cn } from '@/utils/cn';
import { replaceUrlKeepingState } from '@/utils/replaceUrlKeepingState';
@@ -12,19 +10,9 @@ import { WISH_TAB, WISH_TAB_PANEL_ID } from '../_consts/wishTab';
import type { WishTabT } from '../_types/wishTab';
import { parseWishTabParam } from '../_utils/wishSearchParams';
-const TABS: {
- value: WishTabT;
- label: string;
- Icon: ComponentType>;
- path: string;
-}[] = [
- { value: WISH_TAB.ALL, label: '전체 위시', Icon: HeartIconOutline, path: ROUTES.WISH },
- {
- value: WISH_TAB.FOLDER,
- label: '폴더',
- Icon: FolderIconOutline,
- path: ROUTES.WISHLIST_FOLDER_TAB,
- },
+const TABS: { value: WishTabT; label: string; path: string }[] = [
+ { value: WISH_TAB.ALL, label: '전체 위시', path: ROUTES.WISH },
+ { value: WISH_TAB.FOLDER, label: '폴더', path: ROUTES.WISHLIST_FOLDER_TAB },
];
function WishTabBar() {
@@ -33,7 +21,7 @@ function WishTabBar() {
return (
- {TABS.map(({ value, label, Icon, path }) => {
+ {TABS.map(({ value, label, path }) => {
const isActive = activeTab === value;
return (