From ca6bed6f1b636f2d737734f8819a8acd63bc403c Mon Sep 17 00:00:00 2001 From: sangrae Date: Sat, 10 Oct 2026 17:08:28 +0900 Subject: [PATCH 1/8] =?UTF-8?q?feat:=20=ED=96=89=EC=82=AC=20=EC=8B=A0?= =?UTF-8?q?=EC=B2=AD=EB=82=B4=EC=97=AD=20entities=20=EC=B6=94=EA=B0=80?= =?UTF-8?q?=EC=99=80=20=EC=8B=A0=EC=B2=AD=20=EB=AC=B8=ED=95=AD=20=ED=83=80?= =?UTF-8?q?=EC=9E=85=20=EC=9D=B4=EC=A0=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/entities/events/eventsApi.ts | 65 ++++++++++++- src/entities/events/eventsMock.ts | 92 ++++++++++++++++++- src/entities/events/types.ts | 48 ++++++++++ .../events/EventsApplicationScreen.tsx | 2 +- .../events/components/EventsQuestionField.tsx | 8 +- .../events/constants/eventsApplication.ts | 33 +------ src/lib/mockupApi.ts | 17 ++++ 7 files changed, 228 insertions(+), 37 deletions(-) diff --git a/src/entities/events/eventsApi.ts b/src/entities/events/eventsApi.ts index 434f203..491733c 100644 --- a/src/entities/events/eventsApi.ts +++ b/src/entities/events/eventsApi.ts @@ -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 { @@ -14,3 +21,55 @@ export function fetchEvent(eventId: string): Promise { () => 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 { + return mockupApi(MY_APPLICATIONS_KEY, () => [...MY_EVENT_APPLICATIONS_MOCK]); +} + +// 없는 신청내역은 null +export function fetchMyEventApplication( + applicationId: string, +): Promise { + return mockupApi( + `${MY_APPLICATIONS_KEY}/${applicationId}`, + () => + MY_EVENT_APPLICATIONS_MOCK.find( + (application) => application.id === applicationId, + ) ?? null, + ); +} + +// 신청을 취소하고 바뀐 신청내역을 돌려준다. 목록·상세 조회 캐시를 지워 다음 조회가 취소 상태를 받게 한다. +export async function cancelMyEventApplication( + applicationId: string, +): Promise { + 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; +} diff --git a/src/entities/events/eventsMock.ts b/src/entities/events/eventsMock.ts index 653f22c..d240c3f 100644 --- a/src/entities/events/eventsMock.ts +++ b/src/entities/events/eventsMock.ts @@ -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개를 상태만 바꿔 보여주는 것과 같은 이유로, 상세 내용도 공통 상수로 두고 @@ -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 : []; diff --git a/src/entities/events/types.ts b/src/entities/events/types.ts index 140d997..b4c97e9 100644 --- a/src/entities/events/types.ts +++ b/src/entities/events/types.ts @@ -18,3 +18,51 @@ 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"; + +// 내 행사 신청내역. 행사 쪽 문항이 나중에 바뀌어도 내가 답한 그대로 보여 주려고 신청 당시 문항을 같이 들고 있다. +export interface MyEventApplication { + id: string; + eventId: string; + eventTitle: string; + // "2026-05-12T12:00" + eventDateTime: string; + location: string; + status: MyEventApplicationStatus; + appliedAt: string; + cancelledAt: string | null; + questions: EventsQuestion[]; + // 문항 id → 답변. 답하지 않은 선택 문항은 빠진다 + answers: Record; +} diff --git a/src/features/events/EventsApplicationScreen.tsx b/src/features/events/EventsApplicationScreen.tsx index 93c7815..5131d9d 100644 --- a/src/features/events/EventsApplicationScreen.tsx +++ b/src/features/events/EventsApplicationScreen.tsx @@ -12,12 +12,12 @@ import ScreenHeader from "@/components/ui/ScreenHeader"; import ScreenToast from "@/components/ui/ScreenToast"; import SubmittingOverlay from "@/components/ui/SubmittingOverlay"; import { useScreenHeader } from "@/components/ui/useScreenHeader"; +import type { EventsAnswer } from "@/entities/events/types"; import EventsQuestionField from "@/features/events/components/EventsQuestionField"; import EventsSummaryCard from "@/features/events/components/EventsSummaryCard"; import { EVENTS_APPLICATION, EVENTS_OTHER_OPTION_LABEL, - type EventsAnswer, } from "@/features/events/constants/eventsApplication"; import { submitEventsApplication } from "@/features/events/constants/eventsApplicationSubmit"; diff --git a/src/features/events/components/EventsQuestionField.tsx b/src/features/events/components/EventsQuestionField.tsx index 917555f..424c238 100644 --- a/src/features/events/components/EventsQuestionField.tsx +++ b/src/features/events/components/EventsQuestionField.tsx @@ -10,13 +10,15 @@ import { } from "@wanteddev/wds"; import { useId } from "react"; +import type { + EventsAnswer, + EventsChoiceAnswer, + EventsQuestion, +} from "@/entities/events/types"; import { EVENTS_OTHER_MAX_LENGTH, EVENTS_OTHER_OPTION_LABEL, EVENTS_TEXT_MAX_LENGTH, - type EventsAnswer, - type EventsChoiceAnswer, - type EventsQuestion, } from "@/features/events/constants/eventsApplication"; interface EventsQuestionFieldProps { diff --git a/src/features/events/constants/eventsApplication.ts b/src/features/events/constants/eventsApplication.ts index e9b4f0b..3b3b7aa 100644 --- a/src/features/events/constants/eventsApplication.ts +++ b/src/features/events/constants/eventsApplication.ts @@ -1,33 +1,8 @@ import filePencil from "@/assets/icons/events/file-pencil.svg"; - -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; +import type { + EventsQuestion, + EventsTextQuestion, +} from "@/entities/events/types"; export interface EventsApplication { eventName: string; diff --git a/src/lib/mockupApi.ts b/src/lib/mockupApi.ts index 17284bd..7a03467 100644 --- a/src/lib/mockupApi.ts +++ b/src/lib/mockupApi.ts @@ -21,3 +21,20 @@ export function mockupApi(key: string, getData: () => T): Promise { cache.set(key, promise); return promise; } + +// 신청 취소처럼 목데이터를 바꾸는 요청에 쓴다. 조회와 같은 지연을 주지만 캐시하지 않는다. +// 바뀐 데이터를 다시 받게 하려면 응답 뒤 관련 조회 key를 invalidateMockupApi로 지운다. +export async function mockupMutation(mutate: () => T): Promise { + if (import.meta.env.DEV) { + await new Promise((resolve) => setTimeout(resolve, MOCK_DELAY_MS)); + } + // async 함수 안이라 mutate가 던진 에러는 거부된 Promise로 돌아간다 + return mutate(); +} + +// 다음 조회가 바뀐 목데이터로 새 Promise를 만들게 캐시를 지운다. +export function invalidateMockupApi(keys: string[]) { + for (const key of keys) { + cache.delete(key); + } +} From 664b80a258d568a24834bddd818e81f045535747 Mon Sep 17 00:00:00 2001 From: sangrae Date: Sat, 10 Oct 2026 17:13:00 +0900 Subject: [PATCH 2/8] =?UTF-8?q?feat:=20=ED=96=89=EC=82=AC=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=20=ED=83=AD=EC=9D=84=20=EA=B2=BD=EB=A1=9C=EB=A1=9C=20?= =?UTF-8?q?=EB=82=98=EB=88=84=EA=B3=A0=20=EC=8B=A0=EC=B2=AD=EB=82=B4?= =?UTF-8?q?=EC=97=AD=20=EB=B3=B4=EA=B8=B0=20=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/router.tsx | 6 ++++++ src/components/ui/ScreenLayout.tsx | 4 ++++ .../EventsApplicationCompleteScreen.tsx | 6 ++---- src/features/events/EventsListScreen.tsx | 21 +++++++++++++++---- src/features/events/constants/events.ts | 9 ++++++++ 5 files changed, 38 insertions(+), 8 deletions(-) diff --git a/src/app/router.tsx b/src/app/router.tsx index b1b3b4d..4331ce0 100644 --- a/src/app/router.tsx +++ b/src/app/router.tsx @@ -47,6 +47,12 @@ const routes = [ handle: { background: "normal" } satisfies ScreenRouteHandle, path: "/events", }, + { + element: , + // 행사 목록의 신청내역 탭 — 같은 화면이 주소로 탭을 고른다 + handle: { background: "normal" } satisfies ScreenRouteHandle, + path: "/events/applications", + }, { element: , // 카드 없이 구분선으로만 나뉘는 목록이라 화면 전체가 흰 면이다 diff --git a/src/components/ui/ScreenLayout.tsx b/src/components/ui/ScreenLayout.tsx index dfde652..7467530 100644 --- a/src/components/ui/ScreenLayout.tsx +++ b/src/components/ui/ScreenLayout.tsx @@ -32,6 +32,10 @@ function getBottomNavValueFromPath(pathname: string): BottomNavValue { if (pathname.startsWith("/feedbacks")) { return "board"; } + // 행사 목록의 신청내역 탭(/events/applications)도 "행사" 탭 소속이다 + if (pathname === "/events/applications") { + return "event"; + } return "home"; } diff --git a/src/features/events/EventsApplicationCompleteScreen.tsx b/src/features/events/EventsApplicationCompleteScreen.tsx index f886908..d45f615 100644 --- a/src/features/events/EventsApplicationCompleteScreen.tsx +++ b/src/features/events/EventsApplicationCompleteScreen.tsx @@ -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() { @@ -24,7 +22,7 @@ function EventsApplicationCompleteScreen() { primaryAction={{ label: "홈으로 가기", onClick: () => navigate("/") }} secondaryAction={{ label: "신청내역 보기", - onClick: () => navigate(APPLICATION_HISTORY_PATH), + onClick: () => navigate(EVENTS_TAB_PATHS.application), }} title="신청이 완료됐어요" > diff --git a/src/features/events/EventsListScreen.tsx b/src/features/events/EventsListScreen.tsx index ca44c7d..d2f10d6 100644 --- a/src/features/events/EventsListScreen.tsx +++ b/src/features/events/EventsListScreen.tsx @@ -6,20 +6,26 @@ import { } from "@wanteddev/wds"; import { IconBell, IconSearch } from "@wanteddev/wds-icon"; import { Suspense, useState } from "react"; -import { useNavigate } from "react-router-dom"; +import { useLocation, useNavigate } from "react-router-dom"; import FilterChipGroup from "@/components/ui/FilterChipGroup"; import ScreenHeader from "@/components/ui/ScreenHeader"; import { useScreenHeader } from "@/components/ui/useScreenHeader"; import EventsList from "@/features/events/components/EventsList"; import EventsListSkeleton from "@/features/events/components/EventsListSkeleton"; -import { EVENT_STATUS_FILTERS } from "@/features/events/constants/events"; +import { + EVENT_STATUS_FILTERS, + EVENTS_TAB_PATHS, + type EventsTab, +} from "@/features/events/constants/events"; import { getSearchPath } from "@/features/search/constants/search"; // Figma: 행사 (nodeId 1243:70854) function EventsListScreen() { const navigate = useNavigate(); - const [tab, setTab] = useState("event"); + const { pathname } = useLocation(); + const tab: EventsTab = + pathname === EVENTS_TAB_PATHS.application ? "application" : "event"; const [statusFilter, setStatusFilter] = useState("all"); useScreenHeader( @@ -49,7 +55,14 @@ function EventsListScreen() { Figma Tool 프레임(56~88)은 높이 32에 위아래 여백이 없다 — 세로 패딩을 주면 토글과 그 아래 필터 행이 함께 밀린다. */}
- + + navigate(EVENTS_TAB_PATHS[value as EventsTab], { replace: true }) + } + size="small" + value={tab} + > 행사 신청내역 diff --git a/src/features/events/constants/events.ts b/src/features/events/constants/events.ts index 64ac9c9..060c727 100644 --- a/src/features/events/constants/events.ts +++ b/src/features/events/constants/events.ts @@ -5,3 +5,12 @@ export const EVENT_STATUS_FILTERS = [ { label: "모집예정", value: "upcoming" }, { label: "모집종료", value: "closed" }, ] as const; + +// 행사 목록의 행사/신청내역 탭은 경로로 나눈다 — 새로고침·뒤로가기에도 탭이 유지되고, +// 신청 완료 화면처럼 다른 화면에서도 신청내역 탭으로 바로 보낼 수 있다. +export const EVENTS_TAB_PATHS = { + application: "/events/applications", + event: "/events", +} as const; + +export type EventsTab = keyof typeof EVENTS_TAB_PATHS; From e992b8cb123cc92972a578391a66b5e2ec124c71 Mon Sep 17 00:00:00 2001 From: sangrae Date: Sat, 10 Oct 2026 17:20:47 +0900 Subject: [PATCH 3/8] =?UTF-8?q?feat:=20=ED=96=89=EC=82=AC=20=EC=8B=A0?= =?UTF-8?q?=EC=B2=AD=EB=82=B4=EC=97=AD=20=EB=AA=A9=EB=A1=9D=EA=B3=BC=20?= =?UTF-8?q?=EB=B9=88=20=EC=83=81=ED=83=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/features/events/EventsListScreen.tsx | 46 +++++---- .../components/EventsApplicationCard.tsx | 99 +++++++++++++++++++ .../components/EventsApplicationList.tsx | 58 +++++++++++ .../EventsApplicationListSkeleton.tsx | 82 +++++++++++++++ .../events/components/EventsEmptyState.tsx | 30 +++--- 5 files changed, 285 insertions(+), 30 deletions(-) create mode 100644 src/features/events/components/EventsApplicationCard.tsx create mode 100644 src/features/events/components/EventsApplicationList.tsx create mode 100644 src/features/events/components/EventsApplicationListSkeleton.tsx diff --git a/src/features/events/EventsListScreen.tsx b/src/features/events/EventsListScreen.tsx index d2f10d6..5aa9d51 100644 --- a/src/features/events/EventsListScreen.tsx +++ b/src/features/events/EventsListScreen.tsx @@ -2,7 +2,6 @@ import { SegmentedControl, SegmentedControlItem, TopNavigationButton, - Typography, } from "@wanteddev/wds"; import { IconBell, IconSearch } from "@wanteddev/wds-icon"; import { Suspense, useState } from "react"; @@ -11,6 +10,8 @@ import { useLocation, useNavigate } from "react-router-dom"; import FilterChipGroup from "@/components/ui/FilterChipGroup"; import ScreenHeader from "@/components/ui/ScreenHeader"; import { useScreenHeader } from "@/components/ui/useScreenHeader"; +import EventsApplicationList from "@/features/events/components/EventsApplicationList"; +import EventsApplicationListSkeleton from "@/features/events/components/EventsApplicationListSkeleton"; import EventsList from "@/features/events/components/EventsList"; import EventsListSkeleton from "@/features/events/components/EventsListSkeleton"; import { @@ -70,14 +71,16 @@ function EventsListScreen() {
- {/* 필터는 고정하고 목록만 스크롤된다 */} -
- -
+ {/* 필터는 고정하고 목록만 스크롤된다. 신청내역 탭은 Figma(1133:46168)에 필터 행이 없다 */} + {tab === "event" && ( +
+ +
+ )}
{/* 헤더·토글·필터는 데이터와 무관해서 바로 그리고, 데이터를 받는 목록 자리만 스켈레톤으로 채운다 */} @@ -94,15 +97,22 @@ function EventsListScreen() { /> ) : ( -
- - 신청내역은 아직 준비 중이에요 - -
+ }> + + navigate(EVENTS_TAB_PATHS.event, { replace: true }) + } + onSelectApplication={(applicationId) => + navigate(`${EVENTS_TAB_PATHS.application}/${applicationId}`, { + viewTransition: true, + }) + } + onSelectEvent={(eventId) => + navigate(`/events/${eventId}`, { viewTransition: true }) + } + /> + )}
diff --git a/src/features/events/components/EventsApplicationCard.tsx b/src/features/events/components/EventsApplicationCard.tsx new file mode 100644 index 0000000..ee3b828 --- /dev/null +++ b/src/features/events/components/EventsApplicationCard.tsx @@ -0,0 +1,99 @@ +import { Button, ContentBadge, Typography } from "@wanteddev/wds"; + +import type { MyEventApplication } from "@/entities/events/types"; + +interface EventsApplicationCardProps { + application: MyEventApplication; + onSelectEvent: () => void; + onSelectApplication: () => void; +} + +const STATUS_BADGES = { + applied: { color: "green", label: "신청완료" }, + cancelled: { color: "red", label: "신청취소" }, +} as const; + +// "2026-06-04T13:00" → "2026.06.04 13:00" +function formatDateTime(value: string) { + return value.replace("T", " ").replaceAll("-", "."); +} + +// Figma: ApplicationHistory Card (nodeId 1133:46176, 신청취소 1133:46180) — Stream 로컬 카드. +// 날짜 줄은 신청완료면 신청 일시, 신청취소면 취소 일시다. +// 두 버튼은 WDS Button size="small"이 padding 7/14·radius 8·Label 2/Medium까지 Figma와 같다 — +// `행사 상세`는 outlined, `신청 상세`는 solid(Fill/Normal 배경)이고 색은 둘 다 assistive다. +function EventsApplicationCard({ + application, + onSelectEvent, + onSelectApplication, +}: EventsApplicationCardProps) { + const badge = STATUS_BADGES[application.status]; + const isCancelled = + application.status === "cancelled" && application.cancelledAt !== null; + const dateLabel = isCancelled + ? `${formatDateTime(application.cancelledAt ?? "")} 취소` + : `${formatDateTime(application.appliedAt)} 신청`; + + return ( +
+
+ {/* 행사 이미지 API 전까지는 행사 목록 카드와 같은 단색 자리 */} +
+
+ + {badge.label} + +
+ + {application.eventTitle} + + + {dateLabel} + +
+
+
+
+
+ +
+
+ +
+
+
+ ); +} + +export default EventsApplicationCard; diff --git a/src/features/events/components/EventsApplicationList.tsx b/src/features/events/components/EventsApplicationList.tsx new file mode 100644 index 0000000..1b2ce20 --- /dev/null +++ b/src/features/events/components/EventsApplicationList.tsx @@ -0,0 +1,58 @@ +import { Divider } from "@wanteddev/wds"; +import { Fragment, use } from "react"; + +import { fetchMyEventApplications } from "@/entities/events/eventsApi"; +import EventsApplicationCard from "@/features/events/components/EventsApplicationCard"; +import EventsEmptyState from "@/features/events/components/EventsEmptyState"; + +interface EventsApplicationListProps { + onSelectEvent: (eventId: string) => void; + onSelectApplication: (applicationId: string) => void; + onBrowseEvents: () => void; +} + +// Figma: 행사/신청내역 (nodeId 1133:46168), 빈 상태 (1165:63551) +// 카드 사이는 행사 목록과 같은 간격(24)과 구분선이다. +function EventsApplicationList({ + onSelectEvent, + onSelectApplication, + onBrowseEvents, +}: EventsApplicationListProps) { + const applications = use(fetchMyEventApplications()); + + if (applications.length === 0) { + return ( + // Figma는 가운데가 아니라 토글 아래 176px에 둔다(1165:63551, Empty State y=318) +
+ +
+ ); + } + + return ( +
+ {applications.map((application, index) => ( + + {index > 0 && ( +
+ +
+ )} + onSelectApplication(application.id)} + onSelectEvent={() => onSelectEvent(application.eventId)} + /> +
+ ))} +
+ ); +} + +export default EventsApplicationList; diff --git a/src/features/events/components/EventsApplicationListSkeleton.tsx b/src/features/events/components/EventsApplicationListSkeleton.tsx new file mode 100644 index 0000000..e177a80 --- /dev/null +++ b/src/features/events/components/EventsApplicationListSkeleton.tsx @@ -0,0 +1,82 @@ +import { Divider, Skeleton } from "@wanteddev/wds"; +import { Fragment } from "react"; + +import { + SKELETON_ROW_KEYS, + useSkeletonAnimation, +} from "@/components/ui/ScreenSkeleton"; + +// 신청내역을 받는 동안 보이는 스켈레톤 — 신청내역 카드(70px 썸네일 + 배지·제목·날짜 + 버튼 두 개) 배치만 따른다. +function EventsApplicationListSkeleton() { + const animation = useSkeletonAnimation(); + + return ( +
+ {SKELETON_ROW_KEYS.map((key, index) => ( + + {index > 0 && ( +
+ +
+ )} +
+
+ +
+ + + +
+
+
+
+ +
+
+ +
+
+
+
+ ))} +
+ ); +} + +export default EventsApplicationListSkeleton; diff --git a/src/features/events/components/EventsEmptyState.tsx b/src/features/events/components/EventsEmptyState.tsx index b37911b..eece401 100644 --- a/src/features/events/components/EventsEmptyState.tsx +++ b/src/features/events/components/EventsEmptyState.tsx @@ -7,6 +7,9 @@ interface EventsEmptyStateProps { description: string; /** 있을 때만 버튼을 노출한다 — Figma는 모집중 필터에만 "아카이빙 둘러보기"를 둔다 */ actionLabel?: string; + onAction?: () => void; + /** 설명 굵기 — 행사 목록 empty(1165:62725)는 Medium, 신청내역 empty(1410:52742)는 Regular다 */ + descriptionWeight?: "medium" | "regular"; } // Figma: 행사 모집중 empty > Empty State (nodeId 1165:62725) @@ -18,16 +21,14 @@ function EventsEmptyState({ title, description, actionLabel, + onAction, + descriptionWeight = "medium", }: EventsEmptyStateProps) { return ( -
- {/* Figma는 81px 박스 안에 71×70.109 아트를 left 7 / top 8로 얹는다 */} -
- +
+ {/* Figma 그림 자리 81px(size-20.25). 그림(71×70.109)은 SVG 자체 크기로 가운데 둔다 */} +
+
@@ -44,16 +45,21 @@ function EventsEmptyState({ align="center" color="semantic.label.alternative" variant="label1" - weight="medium" + weight={descriptionWeight} > {description}
{actionLabel && ( - // 아카이빙 화면이 아직 없어서 동작은 보류다. - // TODO: 아카이빙 목록 화면(billilge/stream-client-web#31)이 생기면 onClick을 연결한다 - )} From 8855bb4cb8018f251e693143f240e015a1ba7af6 Mon Sep 17 00:00:00 2001 From: sangrae Date: Sat, 10 Oct 2026 17:26:02 +0900 Subject: [PATCH 4/8] =?UTF-8?q?feat:=20=ED=96=89=EC=82=AC=20=EC=8B=A0?= =?UTF-8?q?=EC=B2=AD=EB=82=B4=EC=97=AD=20=EC=83=81=EC=84=B8=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/router.tsx | 7 ++ .../events/EventsApplicationDetailScreen.tsx | 55 +++++++++++++ .../components/EventsApplicationCard.tsx | 20 ++--- .../EventsApplicationDetailContent.tsx | 42 ++++++++++ .../EventsApplicationDetailSkeleton.tsx | 69 +++++++++++++++++ .../components/EventsApplicationSummary.tsx | 77 +++++++++++++++++++ .../events/components/EventsQuestionField.tsx | 49 +++++++++--- src/features/events/constants/events.ts | 11 +++ 8 files changed, 306 insertions(+), 24 deletions(-) create mode 100644 src/features/events/EventsApplicationDetailScreen.tsx create mode 100644 src/features/events/components/EventsApplicationDetailContent.tsx create mode 100644 src/features/events/components/EventsApplicationDetailSkeleton.tsx create mode 100644 src/features/events/components/EventsApplicationSummary.tsx diff --git a/src/app/router.tsx b/src/app/router.tsx index 4331ce0..ac77c8b 100644 --- a/src/app/router.tsx +++ b/src/app/router.tsx @@ -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"; @@ -53,6 +54,12 @@ const routes = [ handle: { background: "normal" } satisfies ScreenRouteHandle, path: "/events/applications", }, + { + element: , + // 하단 탭 없이 회색 배경(기본값) — 흰 카드가 뜬다 + handle: { hasBottomNav: false } satisfies ScreenRouteHandle, + path: "/events/applications/:applicationId", + }, { element: , // 카드 없이 구분선으로만 나뉘는 목록이라 화면 전체가 흰 면이다 diff --git a/src/features/events/EventsApplicationDetailScreen.tsx b/src/features/events/EventsApplicationDetailScreen.tsx new file mode 100644 index 0000000..bdff04a --- /dev/null +++ b/src/features/events/EventsApplicationDetailScreen.tsx @@ -0,0 +1,55 @@ +import { TopNavigationButton } from "@wanteddev/wds"; +import { IconChevronLeft } from "@wanteddev/wds-icon"; +import { Suspense } from "react"; +import { useNavigate, useParams } from "react-router-dom"; + +import ScreenHeader from "@/components/ui/ScreenHeader"; +import { useScreenHeader } from "@/components/ui/useScreenHeader"; +import EventsApplicationDetailContent from "@/features/events/components/EventsApplicationDetailContent"; +import EventsApplicationDetailSkeleton from "@/features/events/components/EventsApplicationDetailSkeleton"; +import { EVENTS_TAB_PATHS } from "@/features/events/constants/events"; + +// Figma: 신청내역 상세 (nodeId 1133:46181), 신청취소 상세 (1133:46216) +function EventsApplicationDetailScreen() { + const { applicationId = "" } = useParams(); + const navigate = useNavigate(); + + // 바로 들어와 뒤로 갈 히스토리가 없으면 신청내역 목록으로 보낸다 + const goBack = () => { + const historyIndex = (window.history.state as { idx?: number } | null)?.idx; + if (historyIndex !== undefined && historyIndex > 0) { + navigate(-1); + return; + } + navigate(EVENTS_TAB_PATHS.application, { replace: true }); + }; + + useScreenHeader( + + + + } + title="신청 상세" + variant="normal" + />, + ); + + return ( +
+ }> + + +
+ ); +} + +export default EventsApplicationDetailScreen; diff --git a/src/features/events/components/EventsApplicationCard.tsx b/src/features/events/components/EventsApplicationCard.tsx index ee3b828..e1b5324 100644 --- a/src/features/events/components/EventsApplicationCard.tsx +++ b/src/features/events/components/EventsApplicationCard.tsx @@ -1,6 +1,10 @@ import { Button, ContentBadge, Typography } from "@wanteddev/wds"; import type { MyEventApplication } from "@/entities/events/types"; +import { + EVENTS_APPLICATION_STATUS_BADGES, + formatApplicationDateTime, +} from "@/features/events/constants/events"; interface EventsApplicationCardProps { application: MyEventApplication; @@ -8,16 +12,6 @@ interface EventsApplicationCardProps { onSelectApplication: () => void; } -const STATUS_BADGES = { - applied: { color: "green", label: "신청완료" }, - cancelled: { color: "red", label: "신청취소" }, -} as const; - -// "2026-06-04T13:00" → "2026.06.04 13:00" -function formatDateTime(value: string) { - return value.replace("T", " ").replaceAll("-", "."); -} - // Figma: ApplicationHistory Card (nodeId 1133:46176, 신청취소 1133:46180) — Stream 로컬 카드. // 날짜 줄은 신청완료면 신청 일시, 신청취소면 취소 일시다. // 두 버튼은 WDS Button size="small"이 padding 7/14·radius 8·Label 2/Medium까지 Figma와 같다 — @@ -27,12 +21,12 @@ function EventsApplicationCard({ onSelectEvent, onSelectApplication, }: EventsApplicationCardProps) { - const badge = STATUS_BADGES[application.status]; + const badge = EVENTS_APPLICATION_STATUS_BADGES[application.status]; const isCancelled = application.status === "cancelled" && application.cancelledAt !== null; const dateLabel = isCancelled - ? `${formatDateTime(application.cancelledAt ?? "")} 취소` - : `${formatDateTime(application.appliedAt)} 신청`; + ? `${formatApplicationDateTime(application.cancelledAt ?? "")} 취소` + : `${formatApplicationDateTime(application.appliedAt)} 신청`; return (
diff --git a/src/features/events/components/EventsApplicationDetailContent.tsx b/src/features/events/components/EventsApplicationDetailContent.tsx new file mode 100644 index 0000000..39e95a4 --- /dev/null +++ b/src/features/events/components/EventsApplicationDetailContent.tsx @@ -0,0 +1,42 @@ +import { use } from "react"; +import { Navigate } from "react-router-dom"; + +import { fetchMyEventApplication } from "@/entities/events/eventsApi"; +import EventsApplicationSummary from "@/features/events/components/EventsApplicationSummary"; +import EventsQuestionField from "@/features/events/components/EventsQuestionField"; +import { EVENTS_TAB_PATHS } from "@/features/events/constants/events"; + +interface EventsApplicationDetailContentProps { + applicationId: string; +} + +// Figma: 신청내역 상세 (nodeId 1133:46181), 신청취소 상세 (1133:46216) +// 신청 당시 문항에 내가 낸 답변을 채워 읽기 전용으로 보여 준다. 취소된 신청은 입력들을 흐리게 한다. +function EventsApplicationDetailContent({ + applicationId, +}: EventsApplicationDetailContentProps) { + const application = use(fetchMyEventApplication(applicationId)); + + // 없는 신청내역이면 볼 내용이 없어서 신청내역 목록으로 돌려보낸다 + if (!application) { + return ; + } + + const isCancelled = application.status === "cancelled"; + + return ( +
+ + {application.questions.map((question) => ( + + ))} +
+ ); +} + +export default EventsApplicationDetailContent; diff --git a/src/features/events/components/EventsApplicationDetailSkeleton.tsx b/src/features/events/components/EventsApplicationDetailSkeleton.tsx new file mode 100644 index 0000000..42aae9c --- /dev/null +++ b/src/features/events/components/EventsApplicationDetailSkeleton.tsx @@ -0,0 +1,69 @@ +import { Skeleton } from "@wanteddev/wds"; + +import { useSkeletonAnimation } from "@/components/ui/ScreenSkeleton"; + +const QUESTION_SKELETON_KEYS = ["question-1", "question-2", "question-3"]; + +// 신청내역 상세를 받는 동안 보이는 스켈레톤 — 행사 요약 카드와 문항 카드 배치만 따른다. +function EventsApplicationDetailSkeleton() { + const animation = useSkeletonAnimation(); + + return ( +
+
+ + + + +
+ {QUESTION_SKELETON_KEYS.map((key) => ( +
+ + + +
+ ))} +
+ ); +} + +export default EventsApplicationDetailSkeleton; diff --git a/src/features/events/components/EventsApplicationSummary.tsx b/src/features/events/components/EventsApplicationSummary.tsx new file mode 100644 index 0000000..4a8dbd7 --- /dev/null +++ b/src/features/events/components/EventsApplicationSummary.tsx @@ -0,0 +1,77 @@ +import { ContentBadge, Typography } from "@wanteddev/wds"; + +import type { MyEventApplication } from "@/entities/events/types"; +import { + EVENTS_APPLICATION_STATUS_BADGES, + formatApplicationDateTime, +} from "@/features/events/constants/events"; + +// Figma: 신청내역 상세 Event Summary (nodeId 1133:46188), 신청취소 상세 (1133:46223) — Stream 로컬 카드. +// 신청 폼·완료 화면의 EventsSummaryCard(아이콘 + 일시·장소)와 달리 "행사·신청·취소" 라벨 행이라 따로 둔다. +function EventsApplicationSummary({ + application, +}: { + application: MyEventApplication; +}) { + const badge = EVENTS_APPLICATION_STATUS_BADGES[application.status]; + const rows = [ + { + label: "행사", + value: `${formatApplicationDateTime(application.eventDateTime)} · ${application.location}`, + }, + { label: "신청", value: formatApplicationDateTime(application.appliedAt) }, + ...(application.cancelledAt + ? [ + { + label: "취소", + value: formatApplicationDateTime(application.cancelledAt), + }, + ] + : []), + ]; + + return ( +
+ + {badge.label} + + + {application.eventTitle} + +
+ {rows.map((row) => ( +
+ + {row.label} + + + {row.value} + +
+ ))} +
+
+ ); +} + +export default EventsApplicationSummary; diff --git a/src/features/events/components/EventsQuestionField.tsx b/src/features/events/components/EventsQuestionField.tsx index 424c238..44b2072 100644 --- a/src/features/events/components/EventsQuestionField.tsx +++ b/src/features/events/components/EventsQuestionField.tsx @@ -24,7 +24,10 @@ import { interface EventsQuestionFieldProps { question: EventsQuestion; answer: EventsAnswer | undefined; - onAnswerChange: (answer: EventsAnswer) => void; + /** 없으면 읽기 전용 — 신청내역 상세처럼 제출한 답변을 보여 주기만 한다 */ + onAnswerChange?: (answer: EventsAnswer) => void; + /** 취소된 신청내역처럼 입력들을 흐리게(비활성) 보여 준다 */ + disabled?: boolean; } const EMPTY_CHOICE_ANSWER: EventsChoiceAnswer = { otherText: "", selected: [] }; @@ -36,11 +39,20 @@ function toChoiceAnswer(answer: EventsAnswer | undefined): EventsChoiceAnswer { // 문항 유형(question.type)별로 컴포넌트를 나누지 않고 이 컴포넌트 하나가 유형에 맞는 입력을 그린다. // 제목 옆 "*"는 WDS `Label required`를 쓰지 않았다 — Figma의 Field Label은 WDS 인스턴스가 아닌 // 로컬 텍스트이고, `*`도 Body 1/Bold(16px)라 Label이 그리는 label1/medium(14px)과 크기가 다르다. +// +// 신청내역 상세(1133:46181)는 같은 카드를 읽기 전용으로 쓴다 — 선택 표시는 그대로 두고 바꿀 수만 없다. +// 신청취소 상세(1133:46216)는 입력들이 흐린 비활성 모양이라 disabled를 넘긴다. function EventsQuestionField({ question, answer, onAnswerChange, + disabled = false, }: EventsQuestionFieldProps) { + const isReadOnly = onAnswerChange === undefined; + // 비활성(취소된 신청)이면 선택지 글자도 Label/Disable로 흐리게 한다(Figma 1133:46240) + const optionColor = disabled + ? "semantic.label.disable" + : "semantic.label.normal"; const id = useId(); const titleId = `${id}-title`; const otherId = `${id}-other`; @@ -58,7 +70,7 @@ function EventsQuestionField({ option === EVENTS_OTHER_OPTION_LABEL && !checked ? "" : choiceAnswer.otherText; - return onAnswerChange({ otherText, selected }); + return onAnswerChange?.({ otherText, selected }); }; return ( @@ -101,14 +113,19 @@ function EventsQuestionField({
toggleOption(option, checked)} + onCheckedChange={ + isReadOnly + ? undefined + : (checked) => toggleOption(option, checked) + } size="small" />