Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@
"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:*",
Expand All @@ -25,6 +27,7 @@
"clsx": "^2.1.1",
"cropperjs": "1.6.2",
"embla-carousel-react": "^8.6.0",
"es-hangul": "^2.4.0",
"gsap": "^3.15.0",
"html-to-image": "^1.11.13",
"next": "16.2.0",
Expand Down
5 changes: 5 additions & 0 deletions apps/web/src/app/wish/_common/_assets/folder-empty.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
28 changes: 28 additions & 0 deletions apps/web/src/app/wish/_common/_components/FolderListThumbnail.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import BaseImage from '@/components/base-image';
import { cn } from '@/utils/cn';

import FolderEmptyImage from '../_assets/folder-empty.svg';

type FolderListThumbnailProps = {
url: string | null;
className?: string;
};

function FolderListThumbnail({ url, className }: FolderListThumbnailProps) {
return (
<div
className={cn(
'relative flex size-10 shrink-0 items-center justify-center overflow-hidden rounded-sm bg-gray-50',
className
)}
>
{url ? (
<BaseImage src={url} alt="" sizes="40px" className="object-cover" />
) : (
<FolderEmptyImage width={30} height={22} />
)}
</div>
);
}

export default FolderListThumbnail;
138 changes: 138 additions & 0 deletions apps/web/src/app/wish/_common/_components/FolderSelectSheet.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
'use client';

import { type FormEvent, useState } from 'react';

import { ChevronBackwardIconFill, FolderAddIconFill } from '@/assets/icons';
import Button from '@/components/button';
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';

type FolderSelectSheetProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
folders: WishFolderT[];
onSelect: (folder: WishFolderT) => void;
onAddFolder: (name: string) => void;
};

function FolderSelectSheet({
open,
onOpenChange,
folders,
onSelect,
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();
onOpenChange(isOpen);
};

const handleAddSubmit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!trimmedFolderName) return;

onAddFolder(trimmedFolderName);
handleBackToList();
};

return (
<Drawer open={open} onOpenChange={handleOpenChange}>
<DrawerContent>
<DrawerDescription className="sr-only">
{isAddView ? '새 폴더 이름을 입력하세요.' : '상품을 옮길 폴더를 선택하세요.'}
</DrawerDescription>
{isAddView ? (
<form onSubmit={handleAddSubmit} className="flex flex-col gap-6">
<Header
left={
<button
type="button"
aria-label="폴더 선택으로 돌아가기"
onClick={handleBackToList}
className="cursor-pointer"
>
<ChevronBackwardIconFill className="size-7.5 text-text-neutral-primary" />
</button>
}
center={
<DrawerTitle className="heading-2-semibold text-text-neutral-primary">
폴더 추가
</DrawerTitle>
}
/>
<div className="flex flex-col gap-3 pb-1.5">
<Input
aria-label="폴더명"
placeholder="폴더명을 입력해주세요."
value={folderName}
onChange={event => setFolderName(event.target.value)}
autoFocus
/>
<Button type="submit" size="lg" disabled={!trimmedFolderName}>
완료
</Button>
</div>
</form>
) : (
<div className="flex flex-col gap-3">
<Header
center={
<DrawerTitle className="heading-2-semibold text-text-neutral-primary">
폴더 선택
</DrawerTitle>
}
right={
<button
type="button"
aria-label="폴더 추가"
onClick={() => setIsAddView(true)}
className="cursor-pointer"
>
<FolderAddIconFill className="size-6 text-icon-neutral-primary" />
</button>
}
/>
<ul className="flex max-h-[50dvh] flex-col overflow-y-auto">
{folders.map(folder => (
<li key={folder.id}>
<button
type="button"
onClick={() => onSelect(folder)}
className="flex h-15 w-full cursor-pointer items-center gap-[13px] text-left"
>
<FolderListThumbnail
url={folder.wishIds[0] ? getMockWishImageUrl(folder.wishIds[0]) : null}
/>
<span className="flex min-w-0 items-center gap-[5px] body-1-medium">
<span className="truncate text-text-neutral-primary">{folder.name}</span>
<span className="shrink-0 text-text-neutral-tertiary">
({folder.wishIds.length})
</span>
</span>
</button>
</li>
))}
</ul>
</div>
)}
</DrawerContent>
</Drawer>
);
}

export default FolderSelectSheet;
47 changes: 47 additions & 0 deletions apps/web/src/app/wish/_common/_components/WishDeleteSheet.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
'use client';

import Button from '@/components/button';
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerHeader,
DrawerTitle,
} from '@/components/drawer';

type WishDeleteSheetProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
selectedCount: number;
onConfirm: () => void;
};

function WishDeleteSheet({ open, onOpenChange, selectedCount, onConfirm }: WishDeleteSheetProps) {
return (
<Drawer open={open} onOpenChange={onOpenChange}>
<DrawerContent>
<DrawerHeader className="gap-1.5">
<DrawerTitle className="heading-2-semibold text-text-neutral-primary">
상품을 삭제할까요?
</DrawerTitle>
<DrawerDescription className="body-2-medium text-text-neutral-tertiary">
선택하신 상품 {selectedCount}개가 삭제됩니다.
</DrawerDescription>
</DrawerHeader>
<div className="flex gap-2.5 pt-6.5 pb-1.5">
<DrawerClose asChild>
<Button variant="secondary" size="lg" className="flex-1">
취소
</Button>
</DrawerClose>
<Button variant="primary" size="lg" className="flex-1" onClick={onConfirm}>
삭제하기
</Button>
</div>
</DrawerContent>
</Drawer>
);
}

export default WishDeleteSheet;
120 changes: 120 additions & 0 deletions apps/web/src/app/wish/_common/_components/WishEditActionBar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
'use client';

import { type ComponentType, type SVGProps, useState } from 'react';

import { FolderIconFill, TrashIconFill } from '@/assets/icons';
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';

type WishEditActionBarProps = {
selectedCount: number;
currentFolderId?: number;
onMove: (folder: WishFolderT) => void;
onDelete: () => void;
};

function ActionButton({
label,
Icon,
disabled,
onClick,
}: {
label: string;
Icon: ComponentType<SVGProps<SVGSVGElement>>;
disabled: boolean;
onClick: () => void;
}) {
return (
<button
type="button"
disabled={disabled}
onClick={onClick}
className={cn(
'flex cursor-pointer flex-col items-center gap-[7px] body-2-semibold disabled:cursor-not-allowed',
disabled ? 'text-text-neutral-tertiary' : 'text-text-neutral-secondary'
)}
>
<Icon
className={cn(
'size-6',
disabled ? 'text-icon-neutral-secondary' : 'text-icon-neutral-primary'
)}
/>
{label}
</button>
);
}

function WishEditActionBar({
selectedCount,
currentFolderId,
onMove,
onDelete,
}: WishEditActionBarProps) {
const [isFolderSheetOpen, setIsFolderSheetOpen] = useState(false);
const [isDeleteSheetOpen, setIsDeleteSheetOpen] = useState(false);
/** TEMP: API 연동 시 폴더 목록 조회·생성 API 로 교체 */
const [folders, setFolders] = useState(() =>
MOCK_FOLDERS.filter(({ id }) => id !== currentFolderId)
);

const isDisabled = selectedCount === 0;

const handleAddFolder = (name: string) => {
setFolders(prev => [{ id: Date.now(), name, wishIds: [] }, ...prev]);
};
Comment thread
coderabbitai[bot] marked this conversation as resolved.

const handleSelectFolder = (folder: WishFolderT) => {
setIsFolderSheetOpen(false);
onMove(folder);
};

const handleConfirmDelete = () => {
setIsDeleteSheetOpen(false);
onDelete();
};

return (
<>
<div
data-bottom-cta
className="fixed right-0 bottom-0 left-0 mx-auto flex w-full max-w-120 justify-center gap-[82px] rounded-t-2xl bg-white pt-4 pb-[34px]"
style={{ zIndex: Z_INDEX.BOTTOM_CTA }}
>
<ActionButton
label="폴더 이동"
Icon={FolderIconFill}
disabled={isDisabled}
onClick={() => setIsFolderSheetOpen(true)}
/>
<ActionButton
label="삭제"
Icon={TrashIconFill}
disabled={isDisabled}
onClick={() => setIsDeleteSheetOpen(true)}
/>
</div>

<FolderSelectSheet
open={isFolderSheetOpen}
onOpenChange={setIsFolderSheetOpen}
folders={folders}
onSelect={handleSelectFolder}
onAddFolder={handleAddFolder}
/>
<WishDeleteSheet
open={isDeleteSheetOpen}
onOpenChange={setIsDeleteSheetOpen}
selectedCount={selectedCount}
onConfirm={handleConfirmDelete}
/>
</>
);
}

export default WishEditActionBar;
29 changes: 29 additions & 0 deletions apps/web/src/app/wish/_common/_components/WishEditHeader.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { Header } from '@/components/header';

type WishEditHeaderProps = {
selectedCount: number;
onDone: () => void;
};

function WishEditHeader({ selectedCount, onDone }: WishEditHeaderProps) {
return (
<Header
center={
<h1 className="heading-1-bold text-text-neutral-primary">
{selectedCount > 0 ? `상품 ${selectedCount}개 선택` : '상품 선택'}
</h1>
}
right={
<button
type="button"
onClick={onDone}
className="cursor-pointer heading-2-semibold text-text-neutral-primary"
>
완료
</button>
}
/>
);
}

export default WishEditHeader;
Loading
Loading