diff --git a/apps/web/package.json b/apps/web/package.json index 447b43ee..9893f4c9 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -14,8 +14,6 @@ "test:e2e:ui": "playwright test --ui" }, "dependencies": { - "@dnd-kit/core": "^6.3.1", - "@dnd-kit/sortable": "^10.0.0", "@microsoft/fetch-event-source": "^2.0.1", "@next/third-parties": "^16.2.9", "@piki/core": "workspace:*", diff --git a/apps/web/src/app/wish/_assets/folder-cover-empty.svg b/apps/web/src/app/wish/_assets/folder-cover-empty.svg new file mode 100644 index 00000000..733d0d80 --- /dev/null +++ b/apps/web/src/app/wish/_assets/folder-cover-empty.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/apps/web/src/app/wish/_common/_components/FolderNameForm.tsx b/apps/web/src/app/wish/_common/_components/FolderNameForm.tsx new file mode 100644 index 00000000..ab936d91 --- /dev/null +++ b/apps/web/src/app/wish/_common/_components/FolderNameForm.tsx @@ -0,0 +1,83 @@ +'use client'; + +import { type FormEvent, useState } from 'react'; + +import { ChevronBackwardIconFill } from '@/assets/icons'; +import Button from '@/components/button'; +import { DrawerTitle } from '@/components/drawer'; +import { Header } from '@/components/header'; +import Input from '@/components/input'; +import { cn } from '@/utils/cn'; + +const MAX_FOLDER_NAME_LENGTH = 20; + +const graphemeSegmenter = new Intl.Segmenter('ko', { granularity: 'grapheme' }); + +/** NOTE: 이모지도 1자로 세야 해서 length 대신 grapheme 단위로 셈 */ +const countGraphemes = (value: string) => Array.from(graphemeSegmenter.segment(value)).length; + +type FolderNameFormProps = { + title: string; + initialName?: string; + onBack: () => void; + onSubmit: (name: string) => void; +}; + +function FolderNameForm({ title, initialName = '', onBack, onSubmit }: FolderNameFormProps) { + const [name, setName] = useState(initialName); + const [isEmptySubmitted, setIsEmptySubmitted] = useState(false); + + const trimmedName = name.trim(); + const isUnchanged = initialName !== '' && trimmedName === initialName; + const isOverLimit = countGraphemes(trimmedName) > MAX_FOLDER_NAME_LENGTH; + + let errorMessage: string | null = null; + if (isOverLimit) errorMessage = `최대 ${MAX_FOLDER_NAME_LENGTH}자까지 입력할 수 있어요.`; + else if (isEmptySubmitted) errorMessage = '폴더 이름을 입력해 주세요.'; + + const handleSubmit = (event: FormEvent) => { + event.preventDefault(); + if (!trimmedName) { + setIsEmptySubmitted(true); + return; + } + + onSubmit(trimmedName); + }; + + return ( +
+
+ + + } + center={ + + {title} + + } + /> +
+ { + setName(event.target.value); + setIsEmptySubmitted(false); + }} + aria-invalid={errorMessage !== null} + {...(errorMessage && { helperText: errorMessage })} + autoFocus + /> + +
+ + ); +} + +export default FolderNameForm; diff --git a/apps/web/src/app/wish/_common/_components/FolderSelectSheet.tsx b/apps/web/src/app/wish/_common/_components/FolderSelectSheet.tsx index e1fdf25b..f4698597 100644 --- a/apps/web/src/app/wish/_common/_components/FolderSelectSheet.tsx +++ b/apps/web/src/app/wish/_common/_components/FolderSelectSheet.tsx @@ -1,16 +1,15 @@ 'use client'; -import { type FormEvent, useState } from 'react'; +import { useState } from 'react'; -import { ChevronBackwardIconFill, FolderAddIconFill } from '@/assets/icons'; -import Button from '@/components/button'; +import { FolderAddIconFill } from '@/assets/icons'; import { Drawer, DrawerContent, DrawerDescription, DrawerTitle } from '@/components/drawer'; import { Header } from '@/components/header'; -import Input from '@/components/input'; import { getMockWishImageUrl } from '../_consts/wishMock'; import type { WishFolderT } from '../_types/wish'; import FolderListThumbnail from './FolderListThumbnail'; +import FolderNameForm from './FolderNameForm'; type FolderSelectSheetProps = { open: boolean; @@ -28,26 +27,15 @@ function FolderSelectSheet({ onAddFolder, }: FolderSelectSheetProps) { const [isAddView, setIsAddView] = useState(false); - const [folderName, setFolderName] = useState(''); - - const trimmedFolderName = folderName.trim(); - - const handleBackToList = () => { - setIsAddView(false); - setFolderName(''); - }; const handleOpenChange = (isOpen: boolean) => { - if (!isOpen) handleBackToList(); + if (!isOpen) setIsAddView(false); onOpenChange(isOpen); }; - const handleAddSubmit = (event: FormEvent) => { - event.preventDefault(); - if (!trimmedFolderName) return; - - onAddFolder(trimmedFolderName); - handleBackToList(); + const handleAddSubmit = (name: string) => { + onAddFolder(name); + setIsAddView(false); }; return ( @@ -57,37 +45,11 @@ function FolderSelectSheet({ {isAddView ? '새 폴더 이름을 입력하세요.' : '상품을 옮길 폴더를 선택하세요.'} {isAddView ? ( -
-
- - - } - center={ - - 폴더 추가 - - } - /> -
- setFolderName(event.target.value)} - autoFocus - /> - -
- + setIsAddView(false)} + onSubmit={handleAddSubmit} + /> ) : (
void; - selectedCount: number; - onConfirm: () => void; -}; - -function WishDeleteSheet({ open, onOpenChange, selectedCount, onConfirm }: WishDeleteSheetProps) { - return ( - - - - - 상품을 삭제할까요? - - - 선택하신 상품 {selectedCount}개가 삭제됩니다. - - -
- - - - -
-
-
- ); -} - -export default WishDeleteSheet; diff --git a/apps/web/src/app/wish/_common/_components/WishEditActionBar.css b/apps/web/src/app/wish/_common/_components/WishEditActionBar.css new file mode 100644 index 00000000..0a702919 --- /dev/null +++ b/apps/web/src/app/wish/_common/_components/WishEditActionBar.css @@ -0,0 +1,4 @@ +/** 탭바가 루트 레이아웃에 있어 편집 중에도 렌더되므로 액션바가 떠 있는 동안 숨김 */ +body:has([data-hide-bottom-tab-bar]) [data-bottom-tab-bar] { + display: none; +} diff --git a/apps/web/src/app/wish/_common/_components/WishEditActionBar.tsx b/apps/web/src/app/wish/_common/_components/WishEditActionBar.tsx index 88fe8393..bc1daa38 100644 --- a/apps/web/src/app/wish/_common/_components/WishEditActionBar.tsx +++ b/apps/web/src/app/wish/_common/_components/WishEditActionBar.tsx @@ -1,21 +1,23 @@ 'use client'; -import { type ComponentType, type SVGProps, useState } from 'react'; +import { type ComponentProps, type ComponentType, type SVGProps, useState } from 'react'; import { FolderIconFill, TrashIconFill } from '@/assets/icons'; +import ConfirmDialog from '@/components/common/confirm-dialog'; import { Z_INDEX } from '@/consts/zIndex'; import { cn } from '@/utils/cn'; import { MOCK_FOLDERS } from '../_consts/wishMock'; import type { WishFolderT } from '../_types/wish'; import FolderSelectSheet from './FolderSelectSheet'; -import WishDeleteSheet from './WishDeleteSheet'; +import './WishEditActionBar.css'; type WishEditActionBarProps = { selectedCount: number; currentFolderId?: number; onMove: (folder: WishFolderT) => void; onDelete: () => void; + deleteConfirm: Pick, 'title' | 'description' | 'icon'>; }; function ActionButton({ @@ -35,13 +37,13 @@ function ActionButton({ disabled={disabled} onClick={onClick} className={cn( - 'flex cursor-pointer flex-col items-center gap-[7px] body-2-semibold disabled:cursor-not-allowed', + 'flex w-25 cursor-pointer items-center justify-center gap-[7px] body-2-semibold disabled:cursor-not-allowed', disabled ? 'text-text-neutral-tertiary' : 'text-text-neutral-secondary' )} > @@ -55,9 +57,10 @@ function WishEditActionBar({ currentFolderId, onMove, onDelete, + deleteConfirm, }: WishEditActionBarProps) { const [isFolderSheetOpen, setIsFolderSheetOpen] = useState(false); - const [isDeleteSheetOpen, setIsDeleteSheetOpen] = useState(false); + const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); /** TEMP: API 연동 시 폴더 목록 조회·생성 API 로 교체 */ const [folders, setFolders] = useState(() => MOCK_FOLDERS.filter(({ id }) => id !== currentFolderId) @@ -75,7 +78,7 @@ function WishEditActionBar({ }; const handleConfirmDelete = () => { - setIsDeleteSheetOpen(false); + setIsDeleteDialogOpen(false); onDelete(); }; @@ -83,7 +86,8 @@ function WishEditActionBar({ <>
setIsFolderSheetOpen(true)} /> + setIsDeleteSheetOpen(true)} + onClick={() => setIsDeleteDialogOpen(true)} />
@@ -107,10 +112,11 @@ function WishEditActionBar({ onSelect={handleSelectFolder} onAddFolder={handleAddFolder} /> - diff --git a/apps/web/src/app/wish/_common/_components/WishEditHeader.tsx b/apps/web/src/app/wish/_common/_components/WishEditHeader.tsx index 337c094b..e43fd613 100644 --- a/apps/web/src/app/wish/_common/_components/WishEditHeader.tsx +++ b/apps/web/src/app/wish/_common/_components/WishEditHeader.tsx @@ -10,7 +10,7 @@ function WishEditHeader({ selectedCount, onDone }: WishEditHeaderProps) {
- {selectedCount > 0 ? `상품 ${selectedCount}개 선택` : '상품 선택'} + {selectedCount > 0 ? `위시 ${selectedCount}개 선택` : '위시 선택'} } right={ diff --git a/apps/web/src/app/wish/_common/_components/WishGrid.tsx b/apps/web/src/app/wish/_common/_components/WishGrid.tsx index d684aa7f..e4dd74c2 100644 --- a/apps/web/src/app/wish/_common/_components/WishGrid.tsx +++ b/apps/web/src/app/wish/_common/_components/WishGrid.tsx @@ -1,3 +1,5 @@ +import type { ReactNode } from 'react'; + import { CheckboxEmptyIconFill, CheckboxSelectedIconFill } from '@/assets/icons'; import { Z_INDEX } from '@/consts/zIndex'; @@ -8,15 +10,17 @@ type WishGridProps = { wishes: WishListItemT[]; selectedWishIds?: Set; onToggleSelect?: (wishId: number) => void; + leadingItem?: ReactNode; }; /** 이미지 preload 개수 */ const PRELOAD_COUNT = 6; /** 위시 3열 그리드 **/ -function WishGrid({ wishes, selectedWishIds, onToggleSelect }: WishGridProps) { +function WishGrid({ wishes, selectedWishIds, onToggleSelect, leadingItem }: WishGridProps) { return (
    + {leadingItem &&
  • {leadingItem}
  • } {wishes.map(({ wish, item, winnerTournamentIds }, index) => { const card = ( - - - ); -} - -export default WishSearchLink; diff --git a/apps/web/src/app/wish/_common/_consts/wishMock.ts b/apps/web/src/app/wish/_common/_consts/wishMock.ts index 37444d88..6e25393b 100644 --- a/apps/web/src/app/wish/_common/_consts/wishMock.ts +++ b/apps/web/src/app/wish/_common/_consts/wishMock.ts @@ -26,9 +26,10 @@ export const MOCK_WISHES: WishListItemT[] = Array.from({ length: 12 }, (_, index export const MOCK_FOLDERS: WishFolderT[] = [ { id: 1, name: '가방 위시템', wishIds: [4, 1, 2, 3, 5, 6] }, - { id: 2, name: '위시', wishIds: [1, 7] }, - { id: 3, name: '여름 코디', wishIds: [5, 8, 9] }, - { id: 4, name: '빈 폴더', wishIds: [] }, + { id: 2, name: '여름 코디', wishIds: [5, 8, 9] }, + { id: 3, name: '신발', wishIds: [1, 7] }, + { id: 4, name: '원피스', wishIds: [10] }, + { id: 5, name: '빈 폴더', wishIds: [] }, ]; export const getMockWishImageUrl = (wishId: number) => diff --git a/apps/web/src/app/wish/_common/_hooks/useWishEditMode.ts b/apps/web/src/app/wish/_common/_hooks/useWishEditMode.ts index 2c838df9..9029c9c6 100644 --- a/apps/web/src/app/wish/_common/_hooks/useWishEditMode.ts +++ b/apps/web/src/app/wish/_common/_hooks/useWishEditMode.ts @@ -18,6 +18,8 @@ export const useWishEditMode = () => { const handleClearSelection = () => setSelectedWishIds(new Set()); + const handleSelectAll = (wishIds: number[]) => setSelectedWishIds(new Set(wishIds)); + const handleEnterEditMode = () => setMode(WISH_MODE.EDIT); const handleExitEditMode = () => { @@ -30,6 +32,7 @@ export const useWishEditMode = () => { selectedWishIds, handleToggleSelect, handleClearSelection, + handleSelectAll, handleEnterEditMode, handleExitEditMode, }; diff --git a/apps/web/src/app/wish/_components/AllWishTabContent.tsx b/apps/web/src/app/wish/_components/AllWishTabContent.tsx index 9405f1ff..8a494890 100644 --- a/apps/web/src/app/wish/_components/AllWishTabContent.tsx +++ b/apps/web/src/app/wish/_components/AllWishTabContent.tsx @@ -1,12 +1,12 @@ -import type { ComponentProps } from 'react'; - import WishGrid from '../_common/_components/WishGrid'; - -export type AllWishTabContentProps = ComponentProps; +import type { AllWishTabContentProps } from '../_types/wishTab'; +import WishAddCard from './WishAddCard'; function AllWishTabContent(props: AllWishTabContentProps) { + const isEditMode = Boolean(props.selectedWishIds); + /** TODO: 위시 상세 링크 연결 시 saveScrollAnchor·useScrollRestoration(scope: all) 추가 */ - return ; + return } />; } export default AllWishTabContent; diff --git a/apps/web/src/app/wish/_components/FolderAddCard.tsx b/apps/web/src/app/wish/_components/FolderAddCard.tsx index 6eda7fca..0aa05c95 100644 --- a/apps/web/src/app/wish/_components/FolderAddCard.tsx +++ b/apps/web/src/app/wish/_components/FolderAddCard.tsx @@ -1,11 +1,18 @@ import { AddIconOutline } from '@/assets/icons'; -// TODO: 폴더 추가 동작 연결 -function FolderAddCard() { +type FolderAddCardProps = { + onClick: () => void; +}; + +function FolderAddCard({ onClick }: FolderAddCardProps) { 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 ( + { + if (!isOpen) onClose(); + }} + > + { + event.preventDefault(); + inputRef.current?.focus(); + }} + className={cn( + 'inset-0 w-full max-w-none translate-x-0 translate-y-0 rounded-none p-0', + isSearching ? 'bg-transparent' : 'overflow-y-auto bg-bg-layer-basement' + )} + > + + 상품명을 입력하고 검색하면 일치하는 위시를 보여줘요. + +
+
+ + + } + center={ + +

내 위시 검색

+
+ } + /> +
+ setKeyword(event.target.value)} + onFocus={() => setIsFocused(true)} + onBlur={() => setIsFocused(false)} + right={ + keyword && ( + + ) + } + /> +
+
+ + {isSearching ? ( +
+ ) : ( +
+

+ 검색 결과 {results.length}개 +

+ {results.length > 0 ? ( + + ) : ( +
+ +

일치하는 상품이 없어요.

+
+ )} +
+ )} + +
+ ); +} + +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 ( +
+ + + +
)}
); diff --git a/apps/web/src/app/wish/_types/wishTab.ts b/apps/web/src/app/wish/_types/wishTab.ts index 780caa6f..fb7775ab 100644 --- a/apps/web/src/app/wish/_types/wishTab.ts +++ b/apps/web/src/app/wish/_types/wishTab.ts @@ -1,3 +1,17 @@ +import type { WishFolderT, WishListItemT } from '../_common/_types/wish'; import type { WISH_TAB } from '../_consts/wishTab'; export type WishTabT = (typeof WISH_TAB)[keyof typeof WISH_TAB]; + +export type AllWishTabContentProps = { + wishes: WishListItemT[]; + selectedWishIds?: Set; + onToggleSelect?: (wishId: number) => void; +}; + +export type FolderTabContentProps = { + folders: WishFolderT[]; + onCreate: (name: string) => void; + onRename: (folderId: number, name: string) => void; + onDelete: (folderId: number) => void; +}; diff --git a/apps/web/src/app/wish/folder/[folderId]/_components/FolderDetailContent.tsx b/apps/web/src/app/wish/folder/[folderId]/_components/FolderDetailContent.tsx index 9e300708..6eaae9fc 100644 --- a/apps/web/src/app/wish/folder/[folderId]/_components/FolderDetailContent.tsx +++ b/apps/web/src/app/wish/folder/[folderId]/_components/FolderDetailContent.tsx @@ -14,11 +14,13 @@ import { useBackWithFallback } from '@/hooks/useBackWithFallback'; import WishEditActionBar from '../../../_common/_components/WishEditActionBar'; import WishEditHeader from '../../../_common/_components/WishEditHeader'; import WishGrid from '../../../_common/_components/WishGrid'; -import WishSearchLink from '../../../_common/_components/WishSearchLink'; 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 FolderWishAddDialog from './FolderWishAddDialog'; + +const MAX_DISPLAY_COUNT = 999; type FolderDetailContentProps = { folderId: number | null; @@ -34,11 +36,13 @@ function FolderDetailContent({ folderId }: FolderDetailContentProps) { const [wishes, setWishes] = useState(() => MOCK_WISHES.filter(({ wish }) => folder?.wishIds.includes(wish.id)) ); + const [isAddDialogOpen, setIsAddDialogOpen] = useState(false); const { mode, selectedWishIds, handleToggleSelect, handleClearSelection, + handleSelectAll, handleEnterEditMode, handleExitEditMode, } = useWishEditMode(); @@ -60,7 +64,7 @@ function FolderDetailContent({ folderId }: FolderDetailContentProps) { toast.success(`상품을 '${targetFolder.name}' 폴더로 이동했어요.`); }; - /** TODO: 폴더에서 빼기·위시 삭제 중 동작 확정 후 API 연결 */ + /** TODO: 폴더에서 빼기 API 연결 */ const handleDelete = () => { setWishes(prev => prev.filter(({ wish }) => !selectedWishIds.has(wish.id))); handleClearSelection(); @@ -70,7 +74,7 @@ function FolderDetailContent({ folderId }: FolderDetailContentProps) { return ( <>
{mode === WISH_MODE.EDIT ? ( @@ -87,7 +91,7 @@ function FolderDetailContent({ folderId }: FolderDetailContentProps) { onClick={() => backWithFallback(ROUTES.WISHLIST_FOLDER_TAB)} className="cursor-pointer" > - + } center={ @@ -95,31 +99,51 @@ function FolderDetailContent({ folderId }: FolderDetailContentProps) { {folder.name} } - right={} />
)} -
-

- {folder.name} - - 전체({wishes.length}) -

- {mode === WISH_MODE.DEFAULT && ( - +
+ {mode === WISH_MODE.EDIT ? ( + <> +

+ {folder.name} + + 전체({wishes.length}) +

+ + + ) : ( + <> +

+ 상품 {wishes.length > MAX_DISPLAY_COUNT ? `${MAX_DISPLAY_COUNT}+` : wishes.length} +

+
+ + + +
+ )}
- {/* TODO: 위시 상세 링크 연결 시 saveScrollAnchor·useScrollRestoration(scope: folderId) 추가 */} + {/** TODO: 위시 상세 링크 연결 시 saveScrollAnchor·useScrollRestoration(scope: folderId) 추가 */} )} + + ); } diff --git a/apps/web/src/app/wish/folder/[folderId]/_components/FolderWishAddDialog.tsx b/apps/web/src/app/wish/folder/[folderId]/_components/FolderWishAddDialog.tsx new file mode 100644 index 00000000..b0c827c2 --- /dev/null +++ b/apps/web/src/app/wish/folder/[folderId]/_components/FolderWishAddDialog.tsx @@ -0,0 +1,69 @@ +'use client'; + +import { useState } from 'react'; + +import { HeartIconFill, ImageIconFill, LinkIconFill } from '@/assets/icons'; +import { Dialog, DialogContent, DialogDescription, DialogTitle } from '@/components/dialog'; +import ByImageDialog from '@/components/get-item-dialog/ByImageDialog'; +import ByLinkDialog from '@/components/get-item-dialog/ByLinkDialog'; +import OptionButton from '@/components/get-item-dialog/OptionButton'; +import { ROUTES } from '@/consts/route'; + +type FolderWishAddDialogProps = { + folderId: number; + open: boolean; + onOpenChange: (open: boolean) => void; +}; + +/** TODO: 링크·이미지로 담은 위시를 이 폴더에도 저장하는 API 연결 */ +function FolderWishAddDialog({ folderId, open, onOpenChange }: FolderWishAddDialogProps) { + const [subDialog, setSubDialog] = useState<'link' | 'image' | null>(null); + + return ( + <> + + + + 폴더에 위시 추가 + + + 저장한 위시, 링크, 이미지 중 하나를 선택해 폴더에 위시를 추가합니다. + +
    + + setSubDialog('link')} + /> + setSubDialog('image')} + /> +
+
+
+ + setSubDialog(isOpen ? 'link' : null)} + /> + setSubDialog(isOpen ? 'image' : null)} + /> + + ); +} + +export default FolderWishAddDialog; diff --git a/apps/web/src/app/wish/folder/[folderId]/add/_components/FolderWishSelectContent.tsx b/apps/web/src/app/wish/folder/[folderId]/add/_components/FolderWishSelectContent.tsx new file mode 100644 index 00000000..1b4e11ad --- /dev/null +++ b/apps/web/src/app/wish/folder/[folderId]/add/_components/FolderWishSelectContent.tsx @@ -0,0 +1,86 @@ +'use client'; + +import { useRouter } from 'next/navigation'; +import { useEffect } from 'react'; +import { toast } from 'sonner'; + +import { CloseIconFill } from '@/assets/icons'; +import BottomCta from '@/components/bottom-cta'; +import Button from '@/components/button'; +import { Header } from '@/components/header'; +import { ROUTES } from '@/consts/route'; +import { Z_INDEX } from '@/consts/zIndex'; +import { useBackWithFallback } from '@/hooks/useBackWithFallback'; + +import WishGrid from '../../../../_common/_components/WishGrid'; +import { MOCK_FOLDERS, MOCK_WISHES } from '../../../../_common/_consts/wishMock'; +import { useWishEditMode } from '../../../../_common/_hooks/useWishEditMode'; + +const MAX_DISPLAY_COUNT = 999; + +type FolderWishSelectContentProps = { + folderId: number | null; +}; + +function FolderWishSelectContent({ folderId }: FolderWishSelectContentProps) { + const router = useRouter(); + const backWithFallback = useBackWithFallback(); + + const folder = MOCK_FOLDERS.find(({ id }) => id === folderId); + /** TEMP: API 연동 시 위시 목록 조회 응답으로 교체 */ + const candidates = MOCK_WISHES.filter(({ wish }) => !folder?.wishIds.includes(wish.id)); + + const { selectedWishIds, handleToggleSelect } = useWishEditMode(); + + useEffect(() => { + if (folder) return; + + toast.error('폴더를 찾을 수 없어요.'); + router.replace(ROUTES.WISHLIST_FOLDER_TAB); + }, [folder, router]); + + if (!folder) return null; + + /** TODO: 폴더에 위시 추가 API 연결 */ + const handleSubmit = () => backWithFallback(ROUTES.WISHLIST_FOLDER(folder.id)); + + return ( + <> +
+
폴더에 위시 추가} + right={ + + } + /> +

+ 상품 {candidates.length > MAX_DISPLAY_COUNT ? `${MAX_DISPLAY_COUNT}+` : candidates.length} +

+
+ + + + + + + + ); +} + +export default FolderWishSelectContent; diff --git a/apps/web/src/app/wish/folder/[folderId]/add/page.tsx b/apps/web/src/app/wish/folder/[folderId]/add/page.tsx new file mode 100644 index 00000000..dbc427a5 --- /dev/null +++ b/apps/web/src/app/wish/folder/[folderId]/add/page.tsx @@ -0,0 +1,19 @@ +import { parseIdParam } from '@/utils/parseIdParam'; + +import FolderWishSelectContent from './_components/FolderWishSelectContent'; + +type FolderWishAddPageProps = { + params: Promise<{ folderId: string }>; +}; + +async function FolderWishAddPage({ params }: FolderWishAddPageProps) { + const { folderId } = await params; + + return ( +
+ +
+ ); +} + +export default FolderWishAddPage; diff --git a/apps/web/src/app/wish/folder/edit/_components/FolderEditContent.tsx b/apps/web/src/app/wish/folder/edit/_components/FolderEditContent.tsx deleted file mode 100644 index 4df60775..00000000 --- a/apps/web/src/app/wish/folder/edit/_components/FolderEditContent.tsx +++ /dev/null @@ -1,132 +0,0 @@ -'use client'; - -import { - DndContext, - type DragEndEvent, - KeyboardSensor, - PointerSensor, - closestCenter, - useSensor, - useSensors, -} from '@dnd-kit/core'; -import { - SortableContext, - arrayMove, - sortableKeyboardCoordinates, - verticalListSortingStrategy, -} from '@dnd-kit/sortable'; -import { josa } from 'es-hangul'; -import { useState } from 'react'; - -import { CloseIconFill, WarningIconFill } from '@/assets/icons'; -import ConfirmDialog from '@/components/common/confirm-dialog'; -import { Header } from '@/components/header'; -import { ROUTES } from '@/consts/route'; -import { Z_INDEX } from '@/consts/zIndex'; -import { useBackWithFallback } from '@/hooks/useBackWithFallback'; -import { cn } from '@/utils/cn'; - -import { MOCK_FOLDERS } from '../../../_common/_consts/wishMock'; -import type { WishFolderT } from '../../../_common/_types/wish'; -import SortableFolderRow from './SortableFolderRow'; - -function FolderEditContent() { - const backWithFallback = useBackWithFallback(); - - /** TEMP: API 연동 시 폴더 목록 조회 응답으로 교체 */ - const [folders, setFolders] = useState(MOCK_FOLDERS); - const [deleteTarget, setDeleteTarget] = useState(null); - - const sensors = useSensors( - useSensor(PointerSensor), - useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }) - ); - - const isChanged = folders.map(({ id }) => id).join() !== MOCK_FOLDERS.map(({ id }) => id).join(); - - const handleDragEnd = ({ active, over }: DragEndEvent) => { - if (!over || active.id === over.id) return; - - setFolders(prev => - arrayMove( - prev, - prev.findIndex(({ id }) => id === active.id), - prev.findIndex(({ id }) => id === over.id) - ) - ); - }; - - const handleClose = () => backWithFallback(ROUTES.WISHLIST_FOLDER_TAB); - - /** TODO: 폴더 삭제·순서 변경 API 연결 */ - const handleDone = () => handleClose(); - - const handleConfirmDelete = () => { - if (!deleteTarget) return; - - setFolders(prev => prev.filter(({ id }) => id !== deleteTarget.id)); - setDeleteTarget(null); - }; - - return ( - <> -
-
- - - } - center={

폴더 편집

} - right={ - - } - /> -
- - - -
    - {folders.map(folder => ( - - ))} -
-
-
- - !open && setDeleteTarget(null)} - icon={} - title={ - deleteTarget - ? `'${deleteTarget.name}'${josa.pick(deleteTarget.name, '을/를')} 삭제할까요?` - : '' - } - description="폴더를 삭제해도 담아둔 위시는 그대로 유지돼요." - confirmLabel="삭제하기" - onConfirm={handleConfirmDelete} - /> - - ); -} - -export default FolderEditContent; diff --git a/apps/web/src/app/wish/folder/edit/_components/SortableFolderRow.tsx b/apps/web/src/app/wish/folder/edit/_components/SortableFolderRow.tsx deleted file mode 100644 index 4c28d279..00000000 --- a/apps/web/src/app/wish/folder/edit/_components/SortableFolderRow.tsx +++ /dev/null @@ -1,72 +0,0 @@ -'use client'; - -import { useSortable } from '@dnd-kit/sortable'; - -import { AlignEqualIconFill, CloseCircularIconFill } from '@/assets/icons'; -import { cn } from '@/utils/cn'; - -import FolderListThumbnail from '../../../_common/_components/FolderListThumbnail'; -import { getMockWishImageUrl } from '../../../_common/_consts/wishMock'; -import type { WishFolderT } from '../../../_common/_types/wish'; - -type SortableFolderRowProps = { - folder: WishFolderT; - onDelete: (folder: WishFolderT) => void; -}; - -function SortableFolderRow({ folder, onDelete }: SortableFolderRowProps) { - const { - attributes, - listeners, - setNodeRef, - setActivatorNodeRef, - transform, - transition, - isDragging, - } = useSortable({ id: folder.id }); - - return ( -
  • - -
    - -

    - {folder.name} - ({folder.wishIds.length}) -

    -
    - -
  • - ); -} - -export default SortableFolderRow; diff --git a/apps/web/src/app/wish/folder/edit/page.tsx b/apps/web/src/app/wish/folder/edit/page.tsx deleted file mode 100644 index 5986a898..00000000 --- a/apps/web/src/app/wish/folder/edit/page.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import FolderEditContent from './_components/FolderEditContent'; - -function FolderEditPage() { - return ( -
    - -
    - ); -} - -export default FolderEditPage; diff --git a/apps/web/src/components/common/confirm-dialog/index.tsx b/apps/web/src/components/common/confirm-dialog/index.tsx index f3d7fbbd..489a1ab0 100644 --- a/apps/web/src/components/common/confirm-dialog/index.tsx +++ b/apps/web/src/components/common/confirm-dialog/index.tsx @@ -45,11 +45,14 @@ function ConfirmDialog({ return ( - + {icon &&
    {icon}
    } - {title} - + {title} + {description} diff --git a/apps/web/src/consts/route.ts b/apps/web/src/consts/route.ts index 02328070..231aff35 100644 --- a/apps/web/src/consts/route.ts +++ b/apps/web/src/consts/route.ts @@ -25,7 +25,7 @@ export const ROUTES = { WISH: '/wish', // TEMP WISHLIST_FOLDER_TAB: '/wish?tab=folder', WISHLIST_FOLDER: (folderId: number) => `/wish/folder/${folderId}`, - WISHLIST_FOLDER_EDIT: '/wish/folder/edit', + WISHLIST_FOLDER_ADD: (folderId: number) => `/wish/folder/${folderId}/add`, NOTIFICATION: '/notification', MYPAGE_EDIT: '/mypage/edit', MYPAGE_WITHDRAW: '/mypage/withdraw', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 55c1af51..231a7db9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -230,12 +230,6 @@ importers: apps/web: dependencies: - '@dnd-kit/core': - specifier: ^6.3.1 - version: 6.3.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@dnd-kit/sortable': - specifier: ^10.0.0 - version: 10.0.0(@dnd-kit/core@6.3.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0) '@microsoft/fetch-event-source': specifier: ^2.0.1 version: 2.0.1 @@ -1093,28 +1087,6 @@ packages: resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} engines: {node: '>=6.9.0'} - '@dnd-kit/accessibility@3.1.1': - resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==} - peerDependencies: - react: 19.1.0 - - '@dnd-kit/core@6.3.1': - resolution: {integrity: sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==} - peerDependencies: - react: 19.1.0 - react-dom: 19.1.0 - - '@dnd-kit/sortable@10.0.0': - resolution: {integrity: sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==} - peerDependencies: - '@dnd-kit/core': ^6.3.0 - react: 19.1.0 - - '@dnd-kit/utilities@3.2.2': - resolution: {integrity: sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==} - peerDependencies: - react: 19.1.0 - '@dotenvx/dotenvx@1.66.0': resolution: {integrity: sha512-qlQFhHUjhRDybrinqLAD0MClVZDOrsq80O8eD5iSjz3Qa/4f3Jg7SQrOaSobrRyP1QaWIYLGtGpj2c7H0D8NUw==} hasBin: true @@ -9403,31 +9375,6 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 - '@dnd-kit/accessibility@3.1.1(react@19.1.0)': - dependencies: - react: 19.1.0 - tslib: 2.8.1 - - '@dnd-kit/core@6.3.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': - dependencies: - '@dnd-kit/accessibility': 3.1.1(react@19.1.0) - '@dnd-kit/utilities': 3.2.2(react@19.1.0) - react: 19.1.0 - react-dom: 19.1.0(react@19.1.0) - tslib: 2.8.1 - - '@dnd-kit/sortable@10.0.0(@dnd-kit/core@6.3.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0)': - dependencies: - '@dnd-kit/core': 6.3.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@dnd-kit/utilities': 3.2.2(react@19.1.0) - react: 19.1.0 - tslib: 2.8.1 - - '@dnd-kit/utilities@3.2.2(react@19.1.0)': - dependencies: - react: 19.1.0 - tslib: 2.8.1 - '@dotenvx/dotenvx@1.66.0': dependencies: commander: 11.1.0