Skip to content
17 changes: 17 additions & 0 deletions docs/conventions/wds-component-usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ hex를 하드코딩하지 않고 `index.css`의 색상 토큰을 그대로 참
- `width` 기본값이 부모 폭을 채우지 않는다 — 안 주면 카드 폭의 절반 정도로 렌더링돼서 `width="100%"`를 줬다.
- 높이 계산용 숨김 textarea(`readonly`, `aria-hidden`)를 하나 더 렌더링한다 — 테스트·자동화에서 `textarea` 셀렉터를 쓸 때 제외해야 한다.
- 단답형(50자)도 같은 `TextArea`를 쓴다(Figma 텍스트 입력 예시 `1133:43105`의 디자이너 메모).
- 글자 스타일이 `body1-reading`(16px)으로 고정돼 있고 prop으로 못 바꾼다. Figma 입력 글자·안내 문구는 `Label 1/Reading`(14px)이라(`1658:183424`), `sx`에 `& textarea, & textarea::placeholder { typographyStyle("label1-reading", "regular") }`를 줘서 덮는다(`EventsQuestionField`의 `TEXT_AREA_TYPOGRAPHY`). 위 숨김 textarea도 같은 글자로 높이를 재야 자동 높이가 맞아서 `textarea` 전체를 잡는다. 처음엔 16px 그대로였다가 행사 신청내역(#115) `/figma-check`에서 잡혔다.
- **`Action Area/Action Area` → `ActionArea background` + `ActionAreaButton`**: `background`를 켜면 `::before`가 영역 위로 `margin-y`(20px)만큼 더 올라가 그라데이션 마스크로 스크롤 내용을 흐리게 덮는다(Figma `Gradient/Solid`와 일치). `divider`는 기본값이 `true`지만 `extra` 모드에서만 선을 그려서 일반 모드에는 영향 없다. Figma Action Area(110px = 위 20 + 버튼 56 + 아래 34)는 버튼 아래가 iOS **Bottom Safe Area까지 합쳐 34px**인데 WDS `ActionArea`는 아래 padding 20px만 줘서, 모자란 **14px**을 `bg-background-elevated-normal` div로 따로 붙였다(`BottomSheet`에서 14px을 더한 것과 같은 이유). 처음엔 Safe Area 34px을 통째로 더해서 버튼 아래가 54px로 벌어졌었다 — ActionArea 자체 padding과 겹치는지 먼저 확인한다. 이때 `--color-background-elevated-normal`(`--semantic-background-elevated-normal` 별칭) 토큰을 추가했다.
- **Action Area 메인 버튼 높이**: `ActionAreaButton`(`main`)은 항상 `Button size="large"`(padding `12px 28px` → **48px**)로 그리는데, Figma `┗ Main Action`은 padding `16px 28px`(**56px**)이다. WDS에 56px 크기가 없어서 `sx={{ paddingBlock: "16px" }}`로 세로 padding만 맞췄다(`ActionAreaButton`은 `props.sx`를 내부 `Button` 스타일 맨 뒤에 붙여서 덮어쓰기가 된다). 빌릴게 대여 바텀시트 Figma(`1422:57205`)의 "대여 신청하기" 버튼도 같은 56px 스펙이지만, `BililgeRentalSheet.tsx`는 아직 `sx` 없이 48px로 렌더링된다(위 "빌릴게 대여 바텀시트" 절의 "정확히 일치" 기록은 높이까지는 대조하지 않은 것으로 보인다).
- **기타(직접 입력) 입력칸은 WDS가 아니라 plain `<input>`**: Figma `Other Option`(`1658:183954`)은 체크박스 아래에 밑줄만 있는 입력칸이라, WDS `TextField`(배경·테두리·12px radius가 있는 박스형)와 생김새가 다르다. 밑줄은 `Primary/Normal` 0.7px(에셋 SVG의 stroke로 확인, `get_variable_defs`만으로는 선 색이 안 나온다). 글자는 `Typography as="input"`으로 Figma `Label 1/Reading - Regular`(`variant="label1-reading"`, 14px·줄 높이 22px)를 받는다(`component-convention.md` 타이포그래피 참고).
Expand Down Expand Up @@ -609,3 +610,19 @@ Figma는 헤더 배경이 없어서 배너 그림자(`Shadow/Spread/Small`, 60px
### 빈 상태는 WDS `FallbackView`가 아니라 홈 로컬 `HomeEmptyState`

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

## 행사 신청내역(`1133:46168` 목록 / `1165:63551` empty / `1133:46181` 상세 / `1133:46131` 신청취소 확인모달 / `1133:46216` 신청취소 상세 + 토스트) 구현 중 확정된 매핑

| Figma | 코드 | 확인 내용 |
|---|---|---|
| `Content Badge` (신청완료·신청취소) | `ContentBadge size="small" variant="solid" color="accent"` | 신청완료 green, 신청취소 red. 목록 카드와 상세가 `EVENTS_APPLICATION_STATUS_BADGES`를 같이 쓴다 |
| 카드 `행사 상세` / `신청 상세` | `Button size="small" color="assistive"` | 앞은 `variant="outlined"`, 뒤는 `variant="solid"`(Fill/Normal 배경). 둘 다 padding 7/14·radius 8·Label 2/Medium이 Figma와 같다 |
| 상세 `신청 취소` | `Button size="medium" color="primary" variant="outlined"` + `sx` | WDS Button 색이 primary/assistive뿐이라 글자·테두리(`box-shadow` inset)만 `Status/Negative`로 덮는다. outlined primary가 SemiBold 글자라 primary를 고른다 |
| 신청취소 확인모달 | `ConfirmModal tone="negative"` | 사물함 신청 확인 모달과 같은 공용 모달이고, `tone="negative"`가 느낌표 아이콘 + 빨간 확인 버튼을 그린다 |
| 취소 완료 토스트 | `ScreenToast variant="positive"` | 체크 아이콘이 WDS 기본값 그대로 |
| 상세 문항 `Control/Checkbox`·`Control/Radio`·`Textinput/Textarea` | 신청 폼의 `EventsQuestionField` 재사용 | 답변 변경 콜백을 넘기지 않으면 읽기 전용(선택 표시는 그대로, 바꿀 수 없음). 신청취소 상세는 `disabled`로 WDS 비활성 모양을 쓰고, 선택지 글자는 `Label/Disable`(Figma `1133:46240`) |

- **카드·요약은 Stream 로컬**: 신청내역 카드(`EventsApplicationCard`, 70px 회색 썸네일 + 배지·제목·일시 + 버튼 2개)와 상세 요약(`EventsApplicationSummary`)은 로컬이다. 요약은 신청 폼·완료 화면의 `EventsSummaryCard`(아이콘 + 일시·장소)와 달리 "행사·신청·취소" 라벨 행이라 따로 뒀다.
- **빈 상태는 `EventsEmptyState` 재사용**: 그림·버튼 모양은 행사 목록 빈 상태와 같고, 설명 굵기만 다르다(행사 목록 Medium, 신청내역 Regular) — `descriptionWeight`로 고른다. 위치는 목록 영역 가운데가 아니라 Figma대로 토글 아래 176px이다.
- **탭은 경로로 나눈다**: 행사 `/events`, 신청내역 `/events/applications`, 상세 `/events/applications/:applicationId`. 같은 목록 화면이 주소로 탭을 고르고, 탭을 바꿀 때는 replace라 히스토리가 쌓이지 않는다. 신청내역 탭은 Figma에 상태 필터 행이 없어서 숨긴다. 하단 탭은 `/events/applications`에서도 "행사"가 선택된다.
- **취소는 목록까지 반영된다**: 취소 API(`cancelMyEventApplication`)가 목데이터를 바꾸고 목록·상세 조회 캐시를 지운다. 상세는 다시 그리는 업데이트를 transition으로 묶어 새 데이터를 받는 동안 스켈레톤 대신 지금 화면을 유지하고, 토스트는 취소된 화면과 함께 뜬다. 실패 토스트 문구("신청 취소에 실패했어요. 다시 시도해 주세요.")는 Figma에 없어 임의로 정했다.
13 changes: 13 additions & 0 deletions src/app/router.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import BililgeListScreen from "@/features/bililge/BililgeListScreen";
import ChatEntryScreen from "@/features/chat/ChatEntryScreen";
import EventsApplicationClosedScreen from "@/features/events/EventsApplicationClosedScreen";
import EventsApplicationCompleteScreen from "@/features/events/EventsApplicationCompleteScreen";
import EventsApplicationDetailScreen from "@/features/events/EventsApplicationDetailScreen";
import EventsApplicationScreen from "@/features/events/EventsApplicationScreen";
import EventsDetailScreen from "@/features/events/EventsDetailScreen";
import EventsListScreen from "@/features/events/EventsListScreen";
Expand Down Expand Up @@ -47,6 +48,18 @@ const routes = [
handle: { background: "normal" } satisfies ScreenRouteHandle,
path: "/events",
},
{
element: <EventsListScreen />,
// 행사 목록의 신청내역 탭 — 같은 화면이 주소로 탭을 고른다
handle: { background: "normal" } satisfies ScreenRouteHandle,
path: "/events/applications",
},
{
element: <EventsApplicationDetailScreen />,
// 하단 탭 없이 회색 배경(기본값) — 흰 카드가 뜬다
handle: { hasBottomNav: false } satisfies ScreenRouteHandle,
path: "/events/applications/:applicationId",
},
{
element: <NoticesListScreen />,
// 카드 없이 구분선으로만 나뉘는 목록이라 화면 전체가 흰 면이다
Expand Down
5 changes: 5 additions & 0 deletions src/components/ui/ScreenLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,11 @@ function getBottomNavValueFromPath(pathname: string): BottomNavValue {
if (pathname.startsWith("/feedbacks")) {
return "board";
}
// 행사 목록의 신청내역 탭(/events/applications)도 "행사" 탭 소속이다.
// 경로는 features/events의 EVENTS_TAB_PATHS와 같지만, 공용 UI가 기능 폴더를 import하지 않도록 여기 그대로 적는다.
if (pathname === "/events/applications") {
return "event";
}
return "home";
}

Expand Down
65 changes: 62 additions & 3 deletions src/entities/events/eventsApi.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
import { EVENTS } from "@/entities/events/eventsMock";
import type { EventItem } from "@/entities/events/types";
import { mockupApi } from "@/lib/mockupApi";
import {
EVENTS,
MY_EVENT_APPLICATIONS_MOCK,
} from "@/entities/events/eventsMock";
import type { EventItem, MyEventApplication } from "@/entities/events/types";
import {
invalidateMockupApi,
mockupApi,
mockupMutation,
} from "@/lib/mockupApi";

// 실 API가 붙으면 함수 안쪽만 요청 코드로 바꾼다 — 화면은 이 함수로만 데이터를 받는다.
export function fetchEvents(): Promise<EventItem[]> {
Expand All @@ -14,3 +21,55 @@ export function fetchEvent(eventId: string): Promise<EventItem | null> {
() => EVENTS.find((event) => event.id === eventId) ?? null,
);
}

// "2026-06-04T13:00" — 목데이터의 일시 형식(분 단위, 현지 시각)
function toLocalDateTime(date: Date) {
const pad = (value: number) => String(value).padStart(2, "0");
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`;
}

const MY_APPLICATIONS_KEY = "events/applications";

export function fetchMyEventApplications(): Promise<MyEventApplication[]> {
return mockupApi(MY_APPLICATIONS_KEY, () => [...MY_EVENT_APPLICATIONS_MOCK]);
}

// 없는 신청내역은 null
export function fetchMyEventApplication(
applicationId: string,
): Promise<MyEventApplication | null> {
return mockupApi(
`${MY_APPLICATIONS_KEY}/${applicationId}`,
() =>
MY_EVENT_APPLICATIONS_MOCK.find(
(application) => application.id === applicationId,
) ?? null,
);
}

// 신청을 취소하고 바뀐 신청내역을 돌려준다. 목록·상세 조회 캐시를 지워 다음 조회가 취소 상태를 받게 한다.
export async function cancelMyEventApplication(
applicationId: string,
): Promise<MyEventApplication> {
const cancelled = await mockupMutation(() => {
const index = MY_EVENT_APPLICATIONS_MOCK.findIndex(
(application) => application.id === applicationId,
);
if (index === -1) {
throw new Error(`신청내역을 찾을 수 없어요: ${applicationId}`);
}
const next: MyEventApplication = {
...MY_EVENT_APPLICATIONS_MOCK[index],
// 실 API에서는 서버가 취소 시각을 정해 돌려준다
cancelledAt: toLocalDateTime(new Date()),
status: "cancelled",
};
MY_EVENT_APPLICATIONS_MOCK[index] = next;
return next;
});
invalidateMockupApi([
MY_APPLICATIONS_KEY,
`${MY_APPLICATIONS_KEY}/${applicationId}`,
]);
return cancelled;
}
92 changes: 91 additions & 1 deletion src/entities/events/eventsMock.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import type { EventItem } from "@/entities/events/types";
import type {
EventItem,
EventsQuestion,
MyEventApplication,
} from "@/entities/events/types";

// Figma 상세(1133:42433 모집중 / 1156:53992 모집예정)의 메타데이터·본문은 목업이라 행사별로 다르지 않다.
// 목록 카드가 같은 행사명 4개를 상태만 바꿔 보여주는 것과 같은 이유로, 상세 내용도 공통 상수로 두고
Expand Down Expand Up @@ -69,3 +73,89 @@ export const EVENTS: EventItem[] = [
title: "소프트웨어융합대학 체육대회",
},
];

// Figma: 행사 신청내역 (nodeId 1133:46168 목록, 1133:46181 상세, 1133:46216 신청취소 상세)
// 행사 상세로 이동할 수 있게 eventId는 위 EVENTS에 있는 행사로 맞춘다.

// 신청 폼(EVENTS_APPLICATION)과 같은 문항 — 신청 당시 문항을 그대로 들고 있는 모양을 흉내 낸다.
const QUESTIONS: EventsQuestion[] = [
{
hasOtherOption: true,
id: "interest",
isRequired: true,
options: ["개발", "디자인", "기획"],
title: "관심 분야를 선택해 주세요.",
type: "multipleChoice",
},
{
id: "afterParty",
isRequired: true,
options: ["참여해요", "참여하지 않아요"],
title: "뒤풀이에 참여하시나요?",
type: "singleChoice",
},
{
id: "question",
isRequired: false,
title: "궁금한 점이 있다면 자유롭게 남겨 주세요.",
type: "longAnswer",
},
];

const APPLICATIONS: MyEventApplication[] = [
{
answers: {
afterParty: "참여해요",
interest: { otherText: "", selected: ["개발", "디자인"] },
question:
"궁금한 점이 하나 있습니다. 행사 당일 학생증을 꼭 챙겨야 하나요?",
},
appliedAt: "2026-06-04T13:00",
cancelledAt: null,
eventDateTime: "2026-06-04T18:30",
eventId: "sw-sports-day-closed",
eventTitle: "소프트웨어융합대학 체육대회",
id: "application-1",
location: "미래관 4층 신관 입구",
questions: QUESTIONS,
status: "applied",
},
{
answers: {
afterParty: "참여하지 않아요",
interest: { otherText: "", selected: ["기획"] },
},
appliedAt: "2026-06-04T13:00",
cancelledAt: null,
eventDateTime: "2026-06-04T18:30",
eventId: "sw-sports-day-upcoming",
eventTitle: "소프트웨어융합대학 체육대회",
id: "application-2",
location: "미래관 4층 신관 입구",
questions: QUESTIONS,
status: "applied",
},
{
answers: {
afterParty: "참여해요",
interest: { otherText: "", selected: ["개발"] },
},
appliedAt: "2026-06-03T11:49",
cancelledAt: "2026-06-04T13:00",
eventDateTime: "2026-06-04T18:30",
eventId: "sw-sports-day-closed",
eventTitle: "소프트웨어융합대학 체육대회",
id: "application-3",
location: "미래관 4층 신관 입구",
questions: QUESTIONS,
status: "cancelled",
},
];

// 상태를 바꿔 신청내역 화면을 확인한다
type MyEventApplicationsMockState = "list" | "empty";
const MY_EVENT_APPLICATIONS_MOCK_STATE: MyEventApplicationsMockState = "list";

// 신청 취소가 이 배열의 항목을 바꾼다(eventsApi의 cancelMyEventApplication) — 목록·상세에 같이 반영된다.
export const MY_EVENT_APPLICATIONS_MOCK: MyEventApplication[] =
MY_EVENT_APPLICATIONS_MOCK_STATE === "list" ? APPLICATIONS : [];
53 changes: 53 additions & 0 deletions src/entities/events/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,56 @@ export interface EventItem {
/** 상세 본문. 줄바꿈을 그대로 살려 렌더링한다 */
description: string;
}

// 행사 신청 문항·답변 — 신청 폼과 신청내역 상세가 같이 쓴다.
interface EventsQuestionBase {
id: string;
title: string;
isRequired: boolean;
}

export interface EventsChoiceQuestion extends EventsQuestionBase {
type: "multipleChoice" | "singleChoice";
options: string[];
// 기타(직접 입력) 선택지를 마지막에 붙일지 — 디자인상 복수 선택에만 있다
hasOtherOption?: boolean;
}

export interface EventsTextQuestion extends EventsQuestionBase {
type: "shortAnswer" | "longAnswer";
}

// type 값 이름은 임시 — 백엔드 스펙이 나오면 거기에 맞춘다
export type EventsQuestion = EventsChoiceQuestion | EventsTextQuestion;

// 복수 선택은 고른 선택지 목록과 기타 입력 내용을 함께 들고 있는다.
// 단일 선택·텍스트형은 문자열 하나.
export interface EventsChoiceAnswer {
selected: string[];
otherText: string;
}

export type EventsAnswer = string | EventsChoiceAnswer;

export type MyEventApplicationStatus = "applied" | "cancelled";

interface MyEventApplicationBase {
id: string;
eventId: string;
eventTitle: string;
// "2026-05-12T12:00"
eventDateTime: string;
location: string;
appliedAt: string;
questions: EventsQuestion[];
// 문항 id → 답변. 답하지 않은 선택 문항은 빠진다
answers: Record<string, EventsAnswer>;
}

// 내 행사 신청내역. 행사 쪽 문항이 나중에 바뀌어도 내가 답한 그대로 보여 주려고 신청 당시 문항을 같이 들고 있다.
// 취소 일시는 신청취소일 때만 있다 — 상태로 갈라 두어 "취소인데 취소 일시가 없는" 경우를 타입에서 막는다.
export type MyEventApplication = MyEventApplicationBase &
(
| { status: "applied"; cancelledAt: null }
| { status: "cancelled"; cancelledAt: string }
);
6 changes: 2 additions & 4 deletions src/features/events/EventsApplicationCompleteScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,9 @@ import { useNavigate } from "react-router-dom";
import CompleteCheck from "@/components/ui/CompleteCheck";
import ResultScreen from "@/components/ui/ResultScreen";
import EventsSummaryCard from "@/features/events/components/EventsSummaryCard";
import { EVENTS_TAB_PATHS } from "@/features/events/constants/events";
import { EVENTS_APPLICATION } from "@/features/events/constants/eventsApplication";

// 신청내역 화면은 아직 없다 — 라우트가 없어서 준비 중 화면(ComingSoonScreen)이 뜬다.
const APPLICATION_HISTORY_PATH = "/my/applications";

// Figma: 행사 신청 완료 페이지 (nodeId 1712:192283)
// 행사 정보는 신청 폼과 마찬가지로 API 연동 전까지 목업 하나를 보여준다.
function EventsApplicationCompleteScreen() {
Expand All @@ -24,7 +22,7 @@ function EventsApplicationCompleteScreen() {
primaryAction={{ label: "홈으로 가기", onClick: () => navigate("/") }}
secondaryAction={{
label: "신청내역 보기",
onClick: () => navigate(APPLICATION_HISTORY_PATH),
onClick: () => navigate(EVENTS_TAB_PATHS.application),
}}
title="신청이 완료됐어요"
>
Expand Down
Loading
Loading