Skip to content
Open
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
20 changes: 20 additions & 0 deletions docs/conventions/wds-component-usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -623,6 +623,26 @@ Figma는 헤더 배경이 없어서 배너 그림자(`Shadow/Spread/Small`, 60px

일러스트 44px + 문구 한 줄 + 작은 버튼이라 행사 목록 Empty State 때와 같은 이유(`FallbackView`의 일러스트 폭·패딩 고정)로 맞지 않는다. 그림은 빌릴게 반납 빈 상태(`icons/bililge-empty/return-items.svg`)·행사 목록 빈 상태(`icons/common/empty-events.svg`)와 같은 도형이라 크기만 줄여 그대로 쓴다.

### 홈에서 다른 화면으로 가는 연결(#114)

| 홈에서 누르는 곳 | 이동 |
|---|---|
| 공지 배너 | 공지 상세 `/notices/:noticeId` (공지가 없을 때의 기본 배너는 누를 수 없다) |
| 신청 카드(행사) | 카드(배지·제목 영역) → 행사 상세 `/events/:eventId`, `신청하기` → 행사 신청 `/events/:eventId/apply` |
| 신청 카드(사물함) | 카드·`신청하기` 둘 다 → 사물함 신청 `/lockers/apply`(사물함은 상세 화면이 없다) |
| 대여 현황 `반납 신청` | 이동하지 않고 홈에서 반납 신청 확인 모달 → 완료 토스트 |
| 대여 현황 `N건 더보기` / 빈 상태 `대여하러 가기` | 빌릴게 반납 탭 `/bililge/returns` / 대여 탭 `/bililge` |
| 신청한 행사 행 / `N건 더보기` / 빈 상태 `행사 둘러보기` | 신청내역 상세 `/events/applications/:applicationId` / 신청내역 탭 `/events/applications` / 행사 탭 `/events` |
| 내 정보 학생회비 납부 여부·열린피드백 작성내역·사물함 배정 상태 | `FEE_PAYMENTS_PATH`·`MY_FEEDBACKS_PATH`·`LOCKER_ASSIGNMENT_PATH`(`/my/fee`·`/my/feedbacks`·`/my/locker`) |
| 헤더 알림 | 알림 목록 `NOTIFICATIONS_PATH`(#108에서 연결) |
| 아카이빙 배너 | 아카이빙 목록 `ARCHIVES_PATH`(`/archives`) — 배너 전체가 버튼이다 |

- **탭은 경로로 연다**: 빌릴게(`/bililge`·`/bililge/returns`)와 행사(`/events`·`/events/applications`) 목록은 같은 화면을 라우터가 `tab` prop으로 골라 넘긴다(화면이 주소를 비교하지 않아 끝에 `/`가 붙어도 맞다). 탭을 바꿀 때는 replace라 히스토리가 쌓이지 않고, 하단 탭은 두 번째 탭 라우트의 `handle.bottomNavValue`로 그 화면이 선택된다.
- **이동 방식**: 상세·신청·내 내역처럼 화면을 쌓는 이동은 `viewTransition`(슬라이드), 하단 탭 화면과 그 탭으로는 바로 바꾼다.
- **반납 신청은 빌릴게 반납 탭과 같은 흐름**: 확인 모달·토스트를 `useBililgeReturnRequest` 훅으로 꺼내 두 화면이 같이 쓴다. 모달 왼쪽 버튼은 Figma(`1133:50014`)대로 `닫기`다 — 처음엔 `수정`으로 잘못 들어가 있었다(사물함 칸 선택 확인 모달의 `수정`은 칸을 다시 고른다는 뜻이라 그대로다).
- **누를 수 있는 줄·카드는 버튼으로 그린다**: 신청 카드는 버튼 안에 버튼을 둘 수 없어서 카드 선택을 배지·제목 영역에만 건다(행사 목록 카드와 같은 방식). 공지 배너의 맨 끝 복제 카드는 키보드 포커스를 받지 않는다. 화면 모양은 버튼으로 바꾸기 전과 픽셀 단위로 같다.
- **아직 연결하지 않은 곳**: 학생회비 미납 줄의 납부하기(#105)는 송금 화면 PR(#107)이 머지된 뒤 연결한다. 헤더 설정은 설정 화면 PR(#97)이 직접 연결한다.

## 내 내역 화면(열린피드백 작성내역 `3147:147748`·`3147:147818`·`3147:147756`·`3147:147779` / 학생회비 납부내역 `3147:147860`·`3147:147879`·`3147:147841` / 사물함 배정 상태 `3147:148357`·`3147:148331`) 구현 중 확정된 매핑

| Figma | 코드 | 확인 내용 |
Expand Down
8 changes: 7 additions & 1 deletion src/app/router.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,9 +50,15 @@ const routes = [
path: "/",
},
{
element: <BililgeListScreen />,
element: <BililgeListScreen tab="rent" />,
path: "/bililge",
},
{
element: <BililgeListScreen tab="return" />,
// 빌릴게의 반납 탭 — 같은 화면을 탭만 바꿔 그리고, 하단 탭은 "빌릴게"가 선택된다
handle: { bottomNavValue: "bililge" } satisfies ScreenRouteHandle,
path: "/bililge/returns",
},
{
element: <EventsListScreen tab="event" />,
// 카드 없이 구분선으로만 나뉘는 목록이라 화면 전체가 흰 면이다
Expand Down
22 changes: 16 additions & 6 deletions src/entities/events/eventsMock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,14 +38,24 @@ const MOCK_DESCRIPTION = `안녕하십니까, 제10대 소프트웨어융합대
// Figma: 행사 Event List (nodeId 1243:70866) 문구를 그대로 옮긴 목데이터 — 실 API 연동 전까지 사용.
//
// Figma 목업은 같은 행사명 4개(모집중 1 / 모집예정 1 / 모집종료 2)를 상태만 바꿔 보여주는데,
// 여기서는 모집중을 비우고 모집종료 중복도 하나로 줄였다(모집예정 1 / 모집종료 1).
// 여기서는 모집종료 중복을 하나로 줄였다(모집중 1 / 모집예정 1 / 모집종료 1).
//
// Empty State(1165:62713)는 Figma가 "모집중" 필터 버전으로만 그려져 있고, 일러스트·문구와
// "아카이빙 둘러보기" 버튼이 그 조합의 스펙이다. 목데이터에 모집중 항목이 있으면 이 화면을
// 아예 볼 수 없어서 모집중을 비웠다. 대신 모집중 상세(1133:42433)는 카드로 진입할 수 없다 —
// 실 API가 붙으면 사라질 제약이고, 지금 확인이 필요하면 아래 항목 하나의 status를 "open"으로
// 되돌리면 된다. 모집예정은 상세 디자인(1156:53992)이 있어 진입 가능하게 남겼다.
// 모집중은 홈 신청 카드가 가리키는 행사라 남겨 둔다(#114). 그래서 모집중 필터의 Empty State(1165:62713,
// "아카이빙 둘러보기" 버튼)는 아래 모집중 항목을 빼야 볼 수 있다.
export const EVENTS: EventItem[] = [
{
actionLabel: "신청하기",
audience: MOCK_AUDIENCE,
description: MOCK_DESCRIPTION,
eventDate: "행사일 2026.06.04",
id: "sw-sports-day-open",
imageCount: 7,
location: MOCK_LOCATION,
schedule: MOCK_SCHEDULE,
status: "open",
statusLabel: "신청마감 D-2",
title: "소프트웨어융합대학 체육대회",
},
{
actionLabel: "모집종료",
audience: MOCK_AUDIENCE,
Expand Down
23 changes: 19 additions & 4 deletions src/features/bililge/BililgeListScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,24 @@ import BililgeItemList from "@/features/bililge/components/BililgeItemList";
import BililgeListSkeleton from "@/features/bililge/components/BililgeListSkeleton";
import BililgeRentalSheet from "@/features/bililge/components/BililgeRentalSheet";
import BililgeReturnSection from "@/features/bililge/components/BililgeReturnSection";
import {
BILILGE_TAB_PATHS,
type BililgeTab,
} from "@/features/bililge/constants/bililge";
import { NOTIFICATIONS_PATH } from "@/features/notifications/constants/notifications";
import { getSearchPath } from "@/features/search/constants/search";

interface BililgeListScreenProps {
/** 대여/반납 탭 — 탭마다 경로가 달라서 라우터가 정해 넘긴다 */
tab: BililgeTab;
}

// Figma: 빌릴게 (nodeId 1243:73331)
function BililgeListScreen() {
function BililgeListScreen({ tab }: BililgeListScreenProps) {
const navigate = useNavigate();
const [tab, setTab] = useState("rent");
// 탭을 바꿀 때마다 히스토리가 쌓이지 않게 replace로 바꾼다
const changeTab = (next: BililgeTab) =>
navigate(BILILGE_TAB_PATHS[next], { replace: true });
const [category, setCategory] = useState("전체");
const [rentalItem, setRentalItem] = useState<BililgeItem | null>(null);
const [rentalSheetOpen, setRentalSheetOpen] = useState(false);
Expand Down Expand Up @@ -59,7 +70,11 @@ function BililgeListScreen() {
Figma Tool 프레임(56~88)은 높이 32에 위아래 여백이 없다 — 세로 패딩을 주면
토글과 그 아래 필터 행이 함께 밀린다(행사·게시판 화면과 같은 규칙). */}
<div className="shrink-0 px-5">
<SegmentedControl onValueChange={setTab} size="small" value={tab}>
<SegmentedControl
onValueChange={(value) => changeTab(value as BililgeTab)}
size="small"
value={tab}
>
<SegmentedControlItem value="rent">대여</SegmentedControlItem>
<SegmentedControlItem value="return">반납</SegmentedControlItem>
</SegmentedControl>
Expand Down Expand Up @@ -91,7 +106,7 @@ function BililgeListScreen() {
/>
</Suspense>
) : (
<BililgeReturnSection onBrowseRentals={() => setTab("rent")} />
<BililgeReturnSection onBrowseRentals={() => changeTab("rent")} />
)}
</div>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,7 @@ function BililgeReturnConfirmModal({
sx={{ flex: 1, padding: "12px 20px" }}
variant="solid"
>
수정
닫기
</Button>
<Button
color="primary"
Expand Down
34 changes: 6 additions & 28 deletions src/features/bililge/components/BililgeReturnSection.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,16 @@
import { Button, Divider, Typography } from "@wanteddev/wds";
import { Fragment, useState } from "react";
import { Fragment } from "react";

import rentalHistoryEmptyIllustration from "@/assets/icons/bililge-empty/rental-history.svg";
import returnItemsEmptyIllustration from "@/assets/icons/bililge-empty/return-items.svg";
import ScreenToast from "@/components/ui/ScreenToast";
import BililgeEmptyState from "@/features/bililge/components/BililgeEmptyState";
import BililgeItemCard from "@/features/bililge/components/BililgeItemCard";
import BililgeRentalHistoryEntry from "@/features/bililge/components/BililgeRentalHistoryEntry";
import BililgeReturnConfirmModal from "@/features/bililge/components/BililgeReturnConfirmModal";
import {
BILILGE_RENTAL_HISTORY,
BILILGE_RETURN_ITEMS,
} from "@/features/bililge/constants/bililgeReturns";
import { useBililgeReturnRequest } from "@/features/bililge/hooks/useBililgeReturnRequest";

interface BililgeReturnSectionProps {
onBrowseRentals?: () => void;
Expand All @@ -22,24 +21,14 @@ interface BililgeReturnSectionProps {
// 필터를 안 그린다.
//
// "반납 신청" 버튼 → 확인 모달(1133:49993) → "신청하기"로 확정하면 완료 토스트(1133:50015)가
// 뜬다("수정"은 모달만 닫는다). 실 백엔드 연동 전이라 신청 자체는 로컬 상태만 바꾸고(토스트만
// 뜬다("닫기"는 모달만 닫는다). 실 백엔드 연동 전이라 신청 자체는 로컬 상태만 바꾸고(토스트만
// 보여줌) 반납 대상 물품 목록·대여 내역은 그대로 둔다 — API가 붙으면 이 부분만 교체하면 된다.
//
// 하단 패딩을 넣지 않는다 — Content(1133:49978) 높이(542)가 두 섹션 높이(196+314)와 gap(32)의
// 합과 정확히 같아서 Figma엔 하단 여백이 0이다. 대여 탭 목록의 pb-4는 앱 전역 스크롤 여백
// 컨벤션이라 그대로 두지만, 반납 화면은 이번에 새로 붙이는 값이라 Figma 그대로 맞춘다.
function BililgeReturnSection({ onBrowseRentals }: BililgeReturnSectionProps) {
const [confirmOpen, setConfirmOpen] = useState(false);
const [toastOpen, setToastOpen] = useState(false);
// 토스트가 떠 있는 동안 다시 확인해도 스크린리더가 재안내하도록, 확인마다 값을 바꿔
// ScreenToast를 새로 마운트한다(EventsApplicationScreen의 실패 토스트와 같은 패턴).
const [toastToken, setToastToken] = useState(0);

const handleConfirm = () => {
setConfirmOpen(false);
setToastOpen(true);
setToastToken((token) => token + 1);
};
const { openReturnRequest, returnRequestDialogs } = useBililgeReturnRequest();

return (
<div className="flex flex-col gap-8 px-5 pt-7">
Expand All @@ -60,7 +49,7 @@ function BililgeReturnSection({ onBrowseRentals }: BililgeReturnSectionProps) {
icon={item.icon}
itemName={item.name}
key={item.id}
onRentRequest={() => setConfirmOpen(true)}
onRentRequest={openReturnRequest}
subtitle={`반납까지 ${item.hoursUntilDue}시간`}
/>
))}
Expand Down Expand Up @@ -118,18 +107,7 @@ function BililgeReturnSection({ onBrowseRentals }: BililgeReturnSectionProps) {
)}
</div>

<BililgeReturnConfirmModal
onCancel={() => setConfirmOpen(false)}
onConfirm={handleConfirm}
open={confirmOpen}
/>
<ScreenToast
key={toastToken}
message="반납 신청이 완료됐어요."
onOpenChange={setToastOpen}
open={toastOpen}
variant="positive"
/>
{returnRequestDialogs}
</div>
);
}
Expand Down
8 changes: 8 additions & 0 deletions src/features/bililge/constants/bililge.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
// 빌릴게의 대여/반납 탭은 경로로 나눈다(행사 목록 EVENTS_TAB_PATHS와 같은 방식) — 새로고침·뒤로가기에도
// 탭이 유지되고, 홈 대여 현황 "더보기"처럼 다른 화면에서도 반납 탭으로 바로 보낼 수 있다.
export const BILILGE_TAB_PATHS = {
rent: "/bililge",
return: "/bililge/returns",
} as const;

export type BililgeTab = keyof typeof BILILGE_TAB_PATHS;
46 changes: 46 additions & 0 deletions src/features/bililge/hooks/useBililgeReturnRequest.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { useState } from "react";

import ScreenToast from "@/components/ui/ScreenToast";
import BililgeReturnConfirmModal from "@/features/bililge/components/BililgeReturnConfirmModal";

// 반납 신청 흐름 — "반납 신청" → 확인 모달(1133:49993) → "신청하기"로 확정하면 완료 토스트(1133:50015).
// "닫기"는 모달만 닫는다.
// 빌릴게 반납 탭과 홈 대여 현황이 같이 쓴다. 실 백엔드 연동 전이라 신청 자체는 토스트만 보여 준다 —
// API가 붙으면 confirm 안에서 요청을 보낸다.
// 모달·토스트는 화면 포털에 그려져서 어디에 두든 화면 위에 뜬다. 쓰는 쪽은 openReturnRequest를 버튼에 걸고
// returnRequestDialogs를 렌더하기만 하면 된다.
export function useBililgeReturnRequest() {
const [confirmOpen, setConfirmOpen] = useState(false);
const [toastOpen, setToastOpen] = useState(false);
// 토스트가 떠 있는 동안 다시 확인해도 스크린리더가 재안내하도록, 확인마다 값을 바꿔
// ScreenToast를 새로 마운트한다(EventsApplicationScreen의 실패 토스트와 같은 패턴).
const [toastToken, setToastToken] = useState(0);

const confirm = () => {
setConfirmOpen(false);
setToastOpen(true);
setToastToken((token) => token + 1);
};

const returnRequestDialogs = (
<>
<BililgeReturnConfirmModal
onCancel={() => setConfirmOpen(false)}
onConfirm={confirm}
open={confirmOpen}
/>
<ScreenToast
key={toastToken}
message="반납 신청이 완료됐어요."
onOpenChange={setToastOpen}
open={toastOpen}
variant="positive"
/>
</>
);

return {
openReturnRequest: () => setConfirmOpen(true),
returnRequestDialogs,
};
}
63 changes: 57 additions & 6 deletions src/features/home/HomeScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@ import { useNavigate } from "react-router-dom";
import chatbotIcon from "@/assets/icons/chat/bot.svg";
import logo from "@/assets/icons/home/logo.svg";
import ScreenHeader from "@/components/ui/ScreenHeader";
import { ARCHIVES_PATH } from "@/features/archives/constants/archives";
import { BILILGE_TAB_PATHS } from "@/features/bililge/constants/bililge";
import { useBililgeReturnRequest } from "@/features/bililge/hooks/useBililgeReturnRequest";
import { EVENTS_TAB_PATHS } from "@/features/events/constants/events";
import { FEE_PAYMENTS_PATH } from "@/features/fee/constants/fee";
import { MY_FEEDBACKS_PATH } from "@/features/feedbacks/constants/feedbacks";
import HomeAppliedEventSection from "@/features/home/components/HomeAppliedEventSection";
import HomeApplyCard from "@/features/home/components/HomeApplyCard";
import HomeArchiveBanner from "@/features/home/components/HomeArchiveBanner";
Expand All @@ -15,6 +21,7 @@ import {
HOME_HAS_UNREAD_NOTIFICATION,
HOME_MOCK_DATA,
} from "@/features/home/constants/homeMock";
import { LOCKER_ASSIGNMENT_PATH } from "@/features/lockers/constants/lockers";
import { NOTIFICATIONS_PATH } from "@/features/notifications/constants/notifications";
import { getSearchPath } from "@/features/search/constants/search";

Expand All @@ -27,6 +34,11 @@ function HomeScreen() {

const [isScrolled, setIsScrolled] = useState(false);

// 상세·신청·내 내역처럼 화면을 쌓는 이동은 슬라이드로, 하단 탭 화면(빌릴게·행사와 그 탭)으로는 바로 바꾼다
const push = (to: string) => navigate(to, { viewTransition: true });
// 대여 현황의 반납 신청은 빌릴게로 보내지 않고 홈에서 바로 확인 모달을 띄운다
const { openReturnRequest, returnRequestDialogs } = useBililgeReturnRequest();

return (
<div className="relative flex h-full flex-col">
{/* 스크롤 영역 자체를 flex로 두면 내용이 길 때 섹션이 줄어들어서(배너 가로 스크롤 영역은 최소 높이가 0) 안쪽에서 쌓는다 */}
Expand Down Expand Up @@ -79,27 +91,65 @@ function HomeScreen() {
/>
</div>
<div className="flex flex-col gap-3 pt-0.5 pb-4">
<HomeNoticeBanner notices={HOME_MOCK_DATA.notices} />
<HomeNoticeBanner
notices={HOME_MOCK_DATA.notices}
onSelect={(noticeId) => push(`/notices/${noticeId}`)}
/>
{/* 배너 카드(relative)가 뒤 섹션보다 나중에 그려져 그림자가 덮지 않게 뒤 섹션도 relative로 둔다 */}
<div className="relative flex flex-col gap-3">
{HOME_MOCK_DATA.applyCards.length > 0 && (
<div className="flex flex-col gap-3 px-5">
{HOME_MOCK_DATA.applyCards.map((card) => (
<HomeApplyCard card={card} key={card.id} />
<HomeApplyCard
card={card}
key={card.id}
// 사물함은 상세 화면이 없어서 카드도 신청(유의사항 시트 → 구역 선택)으로 보낸다
onApply={() =>
push(
card.kind === "event"
? `/events/${card.eventId}/apply`
: "/lockers/apply",
)
}
onSelect={() =>
push(
card.kind === "event"
? `/events/${card.eventId}`
: "/lockers/apply",
)
}
/>
))}
</div>
)}
<div className="px-5">
<HomeRentalSection rentals={HOME_MOCK_DATA.rentals} />
<HomeRentalSection
onBrowse={() => navigate(BILILGE_TAB_PATHS.rent)}
onMore={() => navigate(BILILGE_TAB_PATHS.return)}
onReturnRequest={openReturnRequest}
rentals={HOME_MOCK_DATA.rentals}
/>
</div>
<div className="px-5">
<HomeAppliedEventSection events={HOME_MOCK_DATA.appliedEvents} />
<HomeAppliedEventSection
events={HOME_MOCK_DATA.appliedEvents}
onBrowse={() => navigate(EVENTS_TAB_PATHS.event)}
onMore={() => navigate(EVENTS_TAB_PATHS.application)}
onSelect={(event) =>
push(`${EVENTS_TAB_PATHS.application}/${event.applicationId}`)
}
/>
</div>
<div className="px-5">
<HomeInfoList myInfo={HOME_MOCK_DATA.myInfo} />
<HomeInfoList
myInfo={HOME_MOCK_DATA.myInfo}
onSelectFee={() => push(FEE_PAYMENTS_PATH)}
onSelectFeedbacks={() => push(MY_FEEDBACKS_PATH)}
onSelectLocker={() => push(LOCKER_ASSIGNMENT_PATH)}
/>
</div>
<div className="px-5">
<HomeArchiveBanner />
<HomeArchiveBanner onSelect={() => push(ARCHIVES_PATH)} />
</div>
</div>
</div>
Expand All @@ -112,6 +162,7 @@ function HomeScreen() {
>
<img alt="" src={chatbotIcon} />
</button>
{returnRequestDialogs}
</div>
);
}
Expand Down
Loading