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
5 changes: 4 additions & 1 deletion src/components/ui/ModalShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
68 changes: 56 additions & 12 deletions src/features/billilge/BillilgeListScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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";
Expand All @@ -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 });
Expand All @@ -45,6 +53,51 @@ function BillilgeListScreen({ tab }: BillilgeListScreenProps) {
const [rentalItem, setRentalItem] = useState<BillilgeItem | null>(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<BillilgeItem | null>(
() =>
(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(
<ScreenHeader
title="빌릴게"
Expand Down Expand Up @@ -101,16 +154,7 @@ function BillilgeListScreen({ tab }: BillilgeListScreenProps) {
<Suspense fallback={<BillilgeListSkeleton />}>
<BillilgeItemList
category={listCategory}
onRentRequest={(item) => {
// 바텀시트를 여는 것(슬라이드 애니메이션)은 즉시 반영하고, 그 안의 휠
// 피커(특히 분 60개) 마운트처럼 무거운 작업은 startTransition으로 낮은
// 우선순위로 미뤄서 첫 프레임이 버벅이지 않게 한다 — 처음 열 때만 해당하고,
// rentalItem은 닫아도 null로 안 돌아가서 두 번째부터는 이 마운트 비용 자체가 없다.
setRentalSheetOpen(true);
startTransition(() => {
setRentalItem(item);
});
}}
onRentRequest={openRentalSheet}
/>
</Suspense>
) : (
Expand Down
7 changes: 7 additions & 0 deletions src/features/billilge/constants/billilge.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { getTimeUntil } from "@/lib/date";
import type {
BillilgeCategory,
BillilgeItem,
BillilgeRentalStatus,
} from "@/shared/entities/billilge/billilgeTypes";

Expand All @@ -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: "전자기기",
Expand Down
15 changes: 13 additions & 2 deletions src/features/search/components/SearchCategoryItems.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -124,10 +128,17 @@ function SearchCategoryItems({ entries, footer }: SearchCategoryItemsProps) {
<div className="px-5">
<BillilgeItemCard
bordered
// 대여 바텀시트는 빌릴게 화면 안의 상태라 검색에서는 빌릴게 화면으로 보낸다.
// 대여 바텀시트는 빌릴게 화면 안의 상태라, 물품을 state로 실어 빌릴게 화면으로 보내면
// 빌릴게 화면이 그 물품의 대여 시트를 열어 준다.
icon={item.imageUrl}
itemName={item.name}
onRentRequest={() => navigate("/billilge")}
onRentRequest={() =>
navigate(BILLILGE_TAB_PATHS.rent, {
state: {
rentalItem: item,
} satisfies BillilgeListLocationState,
})
}
subtitle={`수량 ${item.count}`}
/>
</div>
Expand Down
Loading