diff --git a/src/features/events/EventsDetailScreen.tsx b/src/features/events/EventsDetailScreen.tsx index 34e4bbb..8fff24e 100644 --- a/src/features/events/EventsDetailScreen.tsx +++ b/src/features/events/EventsDetailScreen.tsx @@ -3,6 +3,7 @@ import { useNavigate, useParams } from "react-router-dom"; import EventsDetailContent from "@/features/events/components/EventsDetailContent"; import EventsDetailSkeleton from "@/features/events/components/EventsDetailSkeleton"; +import { EVENTS_TAB_PATHS } from "@/features/events/constants/events"; // 행사 상세 화면 — 이동 같은 화면 동작만 정하고, 행사 데이터를 받는 동안은 상세 배치를 따른 // 스켈레톤을 보여준다. 헤더도 데이터(사진 유무)에 따라 달라서(EventsDetailContent 주석 참고) 화면 전체가 데이터 영역이다. @@ -18,6 +19,11 @@ function EventsDetailScreen() { navigate(`/events/${id}/apply`, { viewTransition: true }) } onBack={() => navigate(-1)} + onViewApplication={(applicationId) => + navigate(`${EVENTS_TAB_PATHS.application}/${applicationId}`, { + viewTransition: true, + }) + } /> ); diff --git a/src/features/events/EventsListScreen.tsx b/src/features/events/EventsListScreen.tsx index c5d918b..a1404a1 100644 --- a/src/features/events/EventsListScreen.tsx +++ b/src/features/events/EventsListScreen.tsx @@ -112,6 +112,11 @@ function EventsListScreen({ tab }: EventsListScreenProps) { onSelect={(eventId) => navigate(`/events/${eventId}`, { viewTransition: true }) } + onViewApplication={(applicationId) => + navigate(`${EVENTS_TAB_PATHS.application}/${applicationId}`, { + viewTransition: true, + }) + } statusFilter={listStatusFilter} /> diff --git a/src/features/events/components/EventsCard.tsx b/src/features/events/components/EventsCard.tsx index b39c7de..ef1a13f 100644 --- a/src/features/events/components/EventsCard.tsx +++ b/src/features/events/components/EventsCard.tsx @@ -11,6 +11,8 @@ import type { EventListItem } from "@/shared/entities/events/eventsTypes"; interface EventsCardProps { event: EventListItem; onApply?: () => void; + /** 이미 신청한 행사의 "신청완료" 버튼 탭 — 신청내역 상세로 보낸다 */ + onViewApplication?: (applicationId: number) => void; /** 카드(CTA 제외 영역) 탭 — 상세 화면으로 보낸다 */ onSelect?: () => void; } @@ -22,8 +24,16 @@ interface EventsCardProps { // 색 보정이 필요 없다 — WDS Button의 size="small"이 padding 7/14·radius 8·label2로 Figma와 맞고, // disabled 상태 색(label.assistive + interaction.disable)도 모집예정/모집종료 스펙과 그대로 일치한다 // (button/style.js의 &[aria-disabled='true'] 블록 확인). 그래서 sx 없이 disabled prop만 쓴다. -function EventsCard({ event, onApply, onSelect }: EventsCardProps) { - const { title, recruitStatus, thumbnailUrl } = event; +// +// 이미 신청한 행사는 모집 상태와 상관없이 "신청완료"를 outlined로 그리고, 누르면 신청내역 상세로 보낸다. +// Figma에 이 상태가 없어서 정한 것이다 — disabled로 막으면 모집예정·모집종료와 같은 회색이 돼 구분되지 않는다. +function EventsCard({ + event, + onApply, + onViewApplication, + onSelect, +}: EventsCardProps) { + const { title, recruitStatus, thumbnailUrl, applicationId } = event; const isOpen = recruitStatus === "OPEN"; return ( @@ -70,15 +80,26 @@ function EventsCard({ event, onApply, onSelect }: EventsCardProps) {
- + {applicationId === null ? ( + + ) : ( + + )}
diff --git a/src/features/events/components/EventsDetailContent.tsx b/src/features/events/components/EventsDetailContent.tsx index 9075bec..4658e40 100644 --- a/src/features/events/components/EventsDetailContent.tsx +++ b/src/features/events/components/EventsDetailContent.tsx @@ -27,6 +27,7 @@ interface EventsDetailContentProps { eventId: string; onBack: () => void; onApply: (eventId: string) => void; + onViewApplication: (applicationId: string) => void; } // Figma: 행사 상세 (nodeId 1133:42433 모집중 / 1156:53992 모집예정 / 1156:54125 모집종료) @@ -43,10 +44,13 @@ interface EventsDetailContentProps { // - 모집중: 활성. 단 신청 자격(isFeeEligible)이 없으면 disabled에 "학생회비 납부자만 신청할 수 있어요" // - 모집예정: disabled에 신청 시작 일시("8월 10일 18시 오픈") // - 모집종료: Figma "CTA 없음"(1156:54125)대로 하단 버튼(Action Area)을 그리지 않는다 +// 이미 신청한 행사는 모집 상태와 상관없이 하단 버튼이 "신청내역 보기"가 돼 신청내역 상세로 보낸다 — +// 모집종료여도 내 신청을 확인하러 올 수 있어서 버튼을 그린다. Figma에 이 상태가 없어서 정한 것이다. function EventsDetailContent({ eventId, onBack, onApply, + onViewApplication, }: EventsDetailContentProps) { const { data: event } = useSuspenseQuery(eventsQueries.detail(eventId)); const photoCount = event?.images.length ?? 0; @@ -85,11 +89,25 @@ function EventsDetailContent({ // 레거시 납부자를 미납으로 보여서 쓰지 않는다 const isFeeRequired = !event.isFeeEligible; const canApply = event.recruitStatus === "OPEN" && !isFeeRequired; - const actionLabel = + const applyLabel = event.recruitStatus === "OPEN" && isFeeRequired ? EVENTS_FEE_PAID_ONLY_LABEL : getEventDetailActionLabel(event); - const hasActionArea = event.recruitStatus !== "CLOSED"; + const { applicationId } = event; + const action = + applicationId === null + ? { + disabled: !canApply, + label: applyLabel, + onClick: () => onApply(eventId), + } + : { + disabled: false, + label: "신청내역 보기", + onClick: () => onViewApplication(String(applicationId)), + }; + const hasActionArea = + applicationId !== null || event.recruitStatus !== "CLOSED"; return ( // Figma 상세 루트 배경은 Background/Normal/Normal(#FFFFFF)이다 — ScreenLayout 기본 @@ -218,7 +236,7 @@ function EventsDetailContent({ {/* Action Area가 본문을 덮으므로 끝부분이 영구히 가리지 않도록 같은 높이를 비워둔다. 96px = Action Area 위아래 padding 20+20 + 버튼 56. 데스크톱 프레임에서는 홈 인디케이터 여백 14px이 더 붙는다(아래 filler와 같은 규칙). - 모집종료는 Action Area가 없어서 이 여백도 두지 않는다 — Figma(1156:54125)도 본문에서 끝난다. */} + 신청하지 않은 모집종료는 Action Area가 없어서 이 여백도 두지 않는다 — Figma(1156:54125)도 본문에서 끝난다. */} {hasActionArea &&
}
@@ -228,11 +246,11 @@ function EventsDetailContent({ {/* WDS ActionAreaButton은 항상 Button size="large"(padding 12px 28px → 48px)로 그리는데, Figma Main Action은 padding 16px 28px(56px)이라 세로 padding만 sx로 맞춘다. */} onApply(eventId)} + disabled={action.disabled} + onClick={action.onClick} sx={{ paddingBlock: "16px" }} > - {actionLabel} + {action.label} {/* Figma Action Area(110px)는 버튼 아래가 iOS Home Bar 여백까지 합쳐 34px인데, diff --git a/src/features/events/components/EventsList.tsx b/src/features/events/components/EventsList.tsx index 833a00b..ba1d40d 100644 --- a/src/features/events/components/EventsList.tsx +++ b/src/features/events/components/EventsList.tsx @@ -39,6 +39,7 @@ interface EventsListProps { statusFilter: EventStatusFilter; onSelect: (eventId: string) => void; onApply: (eventId: string) => void; + onViewApplication: (applicationId: string) => void; onBrowseArchives: () => void; } @@ -48,6 +49,7 @@ function EventsList({ statusFilter, onSelect, onApply, + onViewApplication, onBrowseArchives, }: EventsListProps) { // 모집 상태는 서버가 걸러 준다 — 페이지로 나눠 받아서 받은 것만 거르면 다음 페이지의 행사가 빠진다 @@ -104,6 +106,9 @@ function EventsList({ event={event} onApply={() => onApply(String(event.eventId))} onSelect={() => onSelect(String(event.eventId))} + onViewApplication={(applicationId) => + onViewApplication(String(applicationId)) + } /> ))} diff --git a/src/features/search/components/SearchCategoryItems.tsx b/src/features/search/components/SearchCategoryItems.tsx index 620390c..568962b 100644 --- a/src/features/search/components/SearchCategoryItems.tsx +++ b/src/features/search/components/SearchCategoryItems.tsx @@ -3,6 +3,7 @@ import { Fragment, type ReactNode } from "react"; import { Link, useNavigate } from "react-router-dom"; import BillilgeItemCard from "@/features/billilge/components/BillilgeItemCard"; import EventsCard from "@/features/events/components/EventsCard"; +import { EVENTS_TAB_PATHS } from "@/features/events/constants/events"; import FeedbacksCard from "@/features/feedbacks/components/FeedbacksCard"; import { formatFeedbackRound, @@ -63,6 +64,9 @@ function SearchCategoryItems({ entries, footer }: SearchCategoryItemsProps) { event={event} onApply={() => navigate(`/events/${event.eventId}/apply`)} onSelect={() => navigate(`/events/${event.eventId}`)} + onViewApplication={(applicationId) => + navigate(`${EVENTS_TAB_PATHS.application}/${applicationId}`) + } /> ), })); diff --git a/src/shared/entities/events/eventsKeys.ts b/src/shared/entities/events/eventsKeys.ts index d78c201..8faf4d6 100644 --- a/src/shared/entities/events/eventsKeys.ts +++ b/src/shared/entities/events/eventsKeys.ts @@ -3,13 +3,14 @@ import type { EventRecruitStatus } from "@/shared/entities/events/eventsTypes"; // 조회 캐시 key. 조회와 무효화가 같은 함수로 key를 만들어서, 한쪽만 바뀌어 무효화가 빗나가지 않게 한다. // 행사 목록은 모집 상태 필터·검색어마다 따로 받아서 lists() 아래에 둔다 — lists() 하나로 모든 필터를 같이 무효화한다. // 신청내역 상세는 목록 key 아래에 둬서, 목록 key 하나로 목록·상세를 같이 무효화한다. +// 행사 상세는 details() 아래에 둔다 — 신청 취소는 어느 행사인지 받지 않아서 모든 상세를 같이 무효화한다. export const eventsKeys = { root: () => ["events"] as const, lists: () => [...eventsKeys.root(), "list"] as const, list: (recruitStatus: EventRecruitStatus | null, keyword: string | null) => [...eventsKeys.lists(), recruitStatus, keyword] as const, - detail: (eventId: string) => - [...eventsKeys.root(), "detail", eventId] as const, + details: () => [...eventsKeys.root(), "detail"] as const, + detail: (eventId: string) => [...eventsKeys.details(), eventId] as const, form: (eventId: string) => [...eventsKeys.root(), "form", eventId] as const, applications: () => [...eventsKeys.root(), "applications"] as const, application: (applicationId: string) => diff --git a/src/shared/entities/events/eventsQueries.ts b/src/shared/entities/events/eventsQueries.ts index 3d6ad28..e57e2be 100644 --- a/src/shared/entities/events/eventsQueries.ts +++ b/src/shared/entities/events/eventsQueries.ts @@ -13,6 +13,7 @@ import type { EventRecruitStatus, } from "@/shared/entities/events/eventsTypes"; import { homeKeys } from "@/shared/entities/home/homeKeys"; +import { searchKeys } from "@/shared/entities/search/searchKeys"; // 서버 enum(RecruitStatus·RecruitType·QuestionType·EventApplicationStatus) 값 — 타입은 eventsTypes.ts가 여기서 만든다 export const EVENT_RECRUIT_STATUSES = [ @@ -44,6 +45,8 @@ export const eventListItemSchema = z.looseObject({ recruitStatus: z.enum(EVENT_RECRUIT_STATUSES), // 신청 마감일까지 남은 날 수(0이면 오늘 마감). 모집중일 때만 있다 daysUntilDeadline: z.number().nullable(), + // 이 학생이 낸 신청 중 취소하지 않은 것의 ID(stream-server#302). 신청하지 않았거나 취소했으면 null이다 + applicationId: z.number().nullable(), }); const eventListPageSchema = z.looseObject({ @@ -75,6 +78,8 @@ export const eventDetailSchema = z.looseObject({ // 이 학생이 학생회비 조건을 채우는지. 납부자 전용이 아니면 항상 true, 납부자 전용이면 납부자 명부에 있을 때만 true다. // 모집 기간·정원은 반영하지 않아서 recruitStatus와 같이 본다 isFeeEligible: z.boolean(), + // 목록 항목과 같다 — 신청했으면 모집 상태와 상관없이 있다 + applicationId: z.number().nullable(), }); // 신청서 질문 — 양식 조회와 신청 상세가 같이 싣는 부분. 화면에는 eventsQuestions.ts에서 EventsQuestion으로 바꿔 넘긴다 @@ -270,7 +275,8 @@ export const eventsQueries = { }; // 행사 신청(POST /v1/app/events/{eventId}/applications). 신청이 생기고 정원이 차서 모집 상태가 바뀔 수도 있어서 -// 신청내역과 행사 목록·상세를 무효화한다. 홈의 신청 안내 카드(신청 여부)와 신청한 행사도 바뀌어서 홈도 무효화한다. +// 신청내역과 행사 목록·상세를 무효화한다. 홈의 신청 안내 카드(신청 여부)와 신청한 행사도 바뀌어서 홈도 무효화하고, +// 통합 검색의 행사 카드도 신청 여부(applicationId)를 그려서 같이 무효화한다. // 마감·이미 신청·신청 자격 없음으로 실패해도 지금 캐시가 낡았다는 뜻이라 같이 무효화한다. // 신청서 양식은 무효화하지 않는다 — 신청서 화면이 떠 있는 동안 다시 받으면 마감된 양식이 화면을 마감으로 보낸다. // 응답 형식이 달라도 서버에는 이미 신청이 생겼으니 실패로 바꾸지 않고 null을 돌려준다(사물함 신청과 같다). @@ -282,6 +288,7 @@ export function useApplyEventMutation(eventId: string) { queryClient.invalidateQueries({ queryKey: eventsKeys.lists() }), queryClient.invalidateQueries({ queryKey: eventsKeys.detail(eventId) }), queryClient.invalidateQueries({ queryKey: homeKeys.root() }), + queryClient.invalidateQueries({ queryKey: searchKeys.root() }), ]); return useMutation({ @@ -314,7 +321,8 @@ export function useApplyEventMutation(eventId: string) { // 신청 취소(PATCH /v1/app/events/applications/{applicationId}/cancel). 신청 기록은 남고 상태만 취소로 바뀐다. // 성공하면 신청내역 목록·상세를 다시 받아 취소 상태를 보여 준다. 홈의 신청한 행사·신청 안내 카드도 바뀌어서 -// 홈도 무효화한다. 이미 취소된 신청이었다면 지금 캐시가 낡았다는 뜻이라 똑같이 다시 받는다. +// 홈도 무효화한다. 행사 목록·상세·통합 검색도 신청 여부(applicationId)를 그려서 무효화한다 — 취소는 어느 행사인지 +// 받지 않아서 행사 상세는 모두 무효화한다. 이미 취소된 신청이었다면 지금 캐시가 낡았다는 뜻이라 똑같이 다시 받는다. export function useCancelEventApplicationMutation() { const queryClient = useQueryClient(); // Promise를 돌려줘서 다시 받은 데이터가 들어온 뒤에 mutation이 끝나게 한다 — @@ -330,6 +338,9 @@ export function useCancelEventApplicationMutation() { queryKey: eventsKeys.applications(), }), queryClient.invalidateQueries({ queryKey: homeKeys.root() }), + queryClient.invalidateQueries({ queryKey: eventsKeys.lists() }), + queryClient.invalidateQueries({ queryKey: eventsKeys.details() }), + queryClient.invalidateQueries({ queryKey: searchKeys.root() }), ]); return useMutation({