From 42fbd887ebf3a54b7622baa956661cf46249155d Mon Sep 17 00:00:00 2001 From: jjunh33 Date: Fri, 6 Nov 2026 11:44:24 +0900 Subject: [PATCH 1/2] =?UTF-8?q?fix:=20=EB=AA=A8=EB=8B=AC=EC=9D=B4=20?= =?UTF-8?q?=EA=B0=99=EC=9D=80=20=ED=8F=AC=ED=84=B8=EC=9D=98=20=EB=B0=94?= =?UTF-8?q?=ED=85=80=EC=8B=9C=ED=8A=B8=20=EB=92=A4=EB=A1=9C=20=EC=88=A8?= =?UTF-8?q?=EC=A7=80=20=EC=95=8A=EA=B2=8C=20=EC=9C=84=EB=A1=9C=20=EC=98=AC?= =?UTF-8?q?=EB=A6=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/ui/ModalShell.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/components/ui/ModalShell.tsx b/src/components/ui/ModalShell.tsx index 95786d7b..90bf3529 100644 --- a/src/components/ui/ModalShell.tsx +++ b/src/components/ui/ModalShell.tsx @@ -96,7 +96,10 @@ function ModalShell({ // 닫혀 있어도 사라지는 애니메이션 때문에 DOM에는 남는다 — inert로 보조기기·키보드 양쪽에서 제외한다 inert={!open} onKeyDown={handleKeyDown} - className={`absolute inset-0 transition-opacity duration-200 ease-out motion-reduce:transition-none ${ + // BottomSheet와 같은 포털 슬롯에 그려서 z가 없으면 DOM에 붙은 순서로 겹친다. 시트 안에서 여는 모달(빌릴게 대여 확인)은 + // 시트와 같은 렌더에 한꺼번에 마운트되면 모달 포털이 시트보다 먼저 붙어 시트 뒤로 숨는다 — + // 붙는 순서와 상관없이 모달이 늘 시트 위에 오도록 z-10으로 올린다. + className={`absolute inset-0 z-10 transition-opacity duration-200 ease-out motion-reduce:transition-none ${ open ? "pointer-events-auto opacity-100" : "pointer-events-none opacity-0" From 5cf85bc6d315ca9e7a41370ba86efc0ad6c6ad81 Mon Sep 17 00:00:00 2001 From: jjunh33 Date: Fri, 6 Nov 2026 11:44:24 +0900 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20=EA=B2=80=EC=83=89=20=EA=B2=B0?= =?UTF-8?q?=EA=B3=BC=EC=97=90=EC=84=9C=20=EB=8C=80=EC=97=AC=20=EC=8B=A0?= =?UTF-8?q?=EC=B2=AD=ED=95=98=EB=A9=B4=20=EB=B9=8C=EB=A6=B4=EA=B2=8C=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=EC=97=90=EC=84=9C=20=EB=8C=80=EC=97=AC=20?= =?UTF-8?q?=EC=8B=9C=ED=8A=B8=20=EC=97=B4=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/features/billilge/BillilgeListScreen.tsx | 68 +++++++++++++++---- src/features/billilge/constants/billilge.ts | 7 ++ .../search/components/SearchCategoryItems.tsx | 15 +++- 3 files changed, 76 insertions(+), 14 deletions(-) diff --git a/src/features/billilge/BillilgeListScreen.tsx b/src/features/billilge/BillilgeListScreen.tsx index 476469bd..35021b03 100644 --- a/src/features/billilge/BillilgeListScreen.tsx +++ b/src/features/billilge/BillilgeListScreen.tsx @@ -4,8 +4,14 @@ import { TopNavigationButton, } from "@wanteddev/wds"; import { IconBell, IconSearch } from "@wanteddev/wds-icon"; -import { Suspense, startTransition, useDeferredValue, useState } from "react"; -import { useNavigate } from "react-router-dom"; +import { + Suspense, + startTransition, + useDeferredValue, + useEffect, + useState, +} from "react"; +import { useLocation, useNavigate } from "react-router-dom"; import ScreenHeader from "@/components/ui/ScreenHeader"; import { useScreenHeader } from "@/components/ui/useScreenHeader"; @@ -19,6 +25,7 @@ import { BILLILGE_ALL_CATEGORIES, BILLILGE_TAB_PATHS, type BillilgeCategoryFilterValue, + type BillilgeListLocationState, type BillilgeTab, } from "@/features/billilge/constants/billilge"; import { NOTIFICATIONS_PATH } from "@/features/notifications/constants/notifications"; @@ -33,6 +40,7 @@ interface BillilgeListScreenProps { // Figma: 빌릴게 (nodeId 1243:73331) function BillilgeListScreen({ tab }: BillilgeListScreenProps) { const navigate = useNavigate(); + const location = useLocation(); // 탭을 바꿀 때마다 히스토리가 쌓이지 않게 replace로 바꾼다 const changeTab = (next: BillilgeTab) => navigate(BILLILGE_TAB_PATHS[next], { replace: true }); @@ -45,6 +53,51 @@ function BillilgeListScreen({ tab }: BillilgeListScreenProps) { const [rentalItem, setRentalItem] = useState(null); const [rentalSheetOpen, setRentalSheetOpen] = useState(false); + // 물품 카드의 "대여 신청"을 눌렀을 때 대여 시트를 연다. + // 바텀시트를 여는 것(슬라이드 애니메이션)은 즉시 반영하고, 그 안의 휠 + // 피커(특히 분 60개) 마운트처럼 무거운 작업은 startTransition으로 낮은 + // 우선순위로 미뤄서 첫 프레임이 버벅이지 않게 한다 — 처음 열 때만 해당하고, + // rentalItem은 닫아도 null로 안 돌아가서 두 번째부터는 이 마운트 비용 자체가 없다. + const openRentalSheet = (item: BillilgeItem) => { + setRentalSheetOpen(true); + startTransition(() => { + setRentalItem(item); + }); + }; + + // 검색 결과에서 대여 신청을 누르고 넘어오면(navigate state) 화면이 뜬 뒤 그 물품으로 대여 신청을 누른 것과 + // 같이 시트를 연다. 넘어온 물품은 열 때까지 여기 들고 있는다. + const [pendingRentalItem, setPendingRentalItem] = + useState( + () => + (location.state as BillilgeListLocationState | null)?.rentalItem ?? + null, + ); + + // 새로고침·뒤로가기로 돌아왔을 때 다시 열리지 않게 받자마자 state를 비운다(열린피드백 전송 토스트와 같은 방식) + useEffect(() => { + if ((location.state as BillilgeListLocationState | null)?.rentalItem) { + navigate(location.pathname, { replace: true, state: null }); + } + }, [location.pathname, location.state, navigate]); + + // 닫힌 시트(화면 아래)가 한 번 그려진 다음 열어야 목록에서 누를 때처럼 아래에서 올라오는 애니메이션이 보인다. + // 화면이 뜬 직후 바로 열면 닫힌 위치가 그려지기 전에 열린 위치로 바뀌어서 시트가 애니메이션 없이 한 번에 나타난다. + // 그래서 다음 프레임이 그려진 뒤(requestAnimationFrame 두 번)에 연다. + // biome-ignore lint/correctness/useExhaustiveDependencies: 넘어온 물품이 정해질 때만 연다(openRentalSheet는 매 렌더 새로 만들어진다) + useEffect(() => { + if (!pendingRentalItem) { + return; + } + let frameId = requestAnimationFrame(() => { + frameId = requestAnimationFrame(() => { + openRentalSheet(pendingRentalItem); + setPendingRentalItem(null); + }); + }); + return () => cancelAnimationFrame(frameId); + }, [pendingRentalItem]); + useScreenHeader( }> { - // 바텀시트를 여는 것(슬라이드 애니메이션)은 즉시 반영하고, 그 안의 휠 - // 피커(특히 분 60개) 마운트처럼 무거운 작업은 startTransition으로 낮은 - // 우선순위로 미뤄서 첫 프레임이 버벅이지 않게 한다 — 처음 열 때만 해당하고, - // rentalItem은 닫아도 null로 안 돌아가서 두 번째부터는 이 마운트 비용 자체가 없다. - setRentalSheetOpen(true); - startTransition(() => { - setRentalItem(item); - }); - }} + onRentRequest={openRentalSheet} /> ) : ( diff --git a/src/features/billilge/constants/billilge.ts b/src/features/billilge/constants/billilge.ts index 07906e38..db8369bc 100644 --- a/src/features/billilge/constants/billilge.ts +++ b/src/features/billilge/constants/billilge.ts @@ -1,6 +1,7 @@ import { getTimeUntil } from "@/lib/date"; import type { BillilgeCategory, + BillilgeItem, BillilgeRentalStatus, } from "@/shared/entities/billilge/billilgeTypes"; @@ -13,6 +14,12 @@ export const BILLILGE_TAB_PATHS = { export type BillilgeTab = keyof typeof BILLILGE_TAB_PATHS; +// 빌릴게 대여 탭으로 보내면서 넘기는 history state — 검색 결과에서 대여 신청을 누르면 그 물품을 실어 보내서, +// 빌릴게 화면이 들어오자마자 그 물품의 대여 바텀시트를 연다(대여 시트는 빌릴게 화면 안의 상태라 검색 화면에서 직접 열 수 없다). +export interface BillilgeListLocationState { + rentalItem?: BillilgeItem; +} + // 카테고리 칩 라벨 — 칩 값은 서버 ItemCategory를 그대로 쓴다. 순서는 Figma Filter Row(1243:73337) export const BILLILGE_CATEGORY_LABELS = { ELECTRONICS: "전자기기", diff --git a/src/features/search/components/SearchCategoryItems.tsx b/src/features/search/components/SearchCategoryItems.tsx index 620390ce..490e4d1a 100644 --- a/src/features/search/components/SearchCategoryItems.tsx +++ b/src/features/search/components/SearchCategoryItems.tsx @@ -2,6 +2,10 @@ import { Divider } from "@wanteddev/wds"; import { Fragment, type ReactNode } from "react"; import { Link, useNavigate } from "react-router-dom"; import BillilgeItemCard from "@/features/billilge/components/BillilgeItemCard"; +import { + BILLILGE_TAB_PATHS, + type BillilgeListLocationState, +} from "@/features/billilge/constants/billilge"; import EventsCard from "@/features/events/components/EventsCard"; import FeedbacksCard from "@/features/feedbacks/components/FeedbacksCard"; import { @@ -124,10 +128,17 @@ function SearchCategoryItems({ entries, footer }: SearchCategoryItemsProps) {
navigate("/billilge")} + onRentRequest={() => + navigate(BILLILGE_TAB_PATHS.rent, { + state: { + rentalItem: item, + } satisfies BillilgeListLocationState, + }) + } subtitle={`수량 ${item.count}`} />